mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 06:04:51 +00:00
Add context event for non-destructive message modification before LLM calls
- Add contextTransform option to Agent (runs before messageTransformer) - Deep copy messages before passing to contextTransform (modifications are ephemeral) - Add ContextEvent and ContextEventResult types - Add emitContext() to HookRunner (chains multiple handlers) - Wire up in sdk.ts when creating Agent with hooks Enables dynamic context pruning: hooks can modify messages sent to LLM without changing session data. See discussion #330.
This commit is contained in:
parent
9e165d1d81
commit
77fe3f1a13
6 changed files with 89 additions and 2 deletions
|
|
@ -55,6 +55,8 @@ export interface AgentOptions {
|
||||||
transport: AgentTransport;
|
transport: AgentTransport;
|
||||||
// Transform app messages to LLM-compatible messages before sending to transport
|
// Transform app messages to LLM-compatible messages before sending to transport
|
||||||
messageTransformer?: (messages: AppMessage[]) => Message[] | Promise<Message[]>;
|
messageTransformer?: (messages: AppMessage[]) => Message[] | Promise<Message[]>;
|
||||||
|
// Called before messageTransformer - can modify messages before they're sent to LLM (non-destructive)
|
||||||
|
contextTransform?: (messages: AppMessage[]) => Promise<AppMessage[] | undefined>;
|
||||||
// Queue mode: "all" = send all queued messages at once, "one-at-a-time" = send one queued message per turn
|
// Queue mode: "all" = send all queued messages at once, "one-at-a-time" = send one queued message per turn
|
||||||
queueMode?: "all" | "one-at-a-time";
|
queueMode?: "all" | "one-at-a-time";
|
||||||
}
|
}
|
||||||
|
|
@ -75,6 +77,7 @@ export class Agent {
|
||||||
private abortController?: AbortController;
|
private abortController?: AbortController;
|
||||||
private transport: AgentTransport;
|
private transport: AgentTransport;
|
||||||
private messageTransformer: (messages: AppMessage[]) => Message[] | Promise<Message[]>;
|
private messageTransformer: (messages: AppMessage[]) => Message[] | Promise<Message[]>;
|
||||||
|
private contextTransform?: (messages: AppMessage[]) => Promise<AppMessage[] | undefined>;
|
||||||
private messageQueue: Array<QueuedMessage<AppMessage>> = [];
|
private messageQueue: Array<QueuedMessage<AppMessage>> = [];
|
||||||
private queueMode: "all" | "one-at-a-time";
|
private queueMode: "all" | "one-at-a-time";
|
||||||
private runningPrompt?: Promise<void>;
|
private runningPrompt?: Promise<void>;
|
||||||
|
|
@ -84,6 +87,7 @@ export class Agent {
|
||||||
this._state = { ...this._state, ...opts.initialState };
|
this._state = { ...this._state, ...opts.initialState };
|
||||||
this.transport = opts.transport;
|
this.transport = opts.transport;
|
||||||
this.messageTransformer = opts.messageTransformer || defaultMessageTransformer;
|
this.messageTransformer = opts.messageTransformer || defaultMessageTransformer;
|
||||||
|
this.contextTransform = opts.contextTransform;
|
||||||
this.queueMode = opts.queueMode || "one-at-a-time";
|
this.queueMode = opts.queueMode || "one-at-a-time";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -298,7 +302,18 @@ export class Agent {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const llmMessages = await this.messageTransformer(this._state.messages);
|
// Apply context transform (hooks can modify messages non-destructively)
|
||||||
|
// Deep copy so modifications don't affect the original state
|
||||||
|
let messagesToSend = this._state.messages;
|
||||||
|
if (this.contextTransform) {
|
||||||
|
const messagesCopy = JSON.parse(JSON.stringify(messagesToSend)) as AppMessage[];
|
||||||
|
const transformed = await this.contextTransform(messagesCopy);
|
||||||
|
if (transformed) {
|
||||||
|
messagesToSend = transformed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const llmMessages = await this.messageTransformer(messagesToSend);
|
||||||
|
|
||||||
return { llmMessages, cfg, model };
|
return { llmMessages, cfg, model };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,7 @@ interface HookUIContext {
|
||||||
|
|
||||||
See also: `CustomEntry<T>` for storing hook state that does NOT participate in context.
|
See also: `CustomEntry<T>` for storing hook state that does NOT participate in context.
|
||||||
|
|
||||||
**New: `context` event (TODO)**
|
**New: `context` event ✅**
|
||||||
|
|
||||||
Fires before messages are sent to the LLM, allowing hooks to modify context non-destructively.
|
Fires before messages are sent to the LLM, allowing hooks to modify context non-destructively.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ export type {
|
||||||
AgentEndEvent,
|
AgentEndEvent,
|
||||||
AgentStartEvent,
|
AgentStartEvent,
|
||||||
BashToolResultEvent,
|
BashToolResultEvent,
|
||||||
|
ContextEvent,
|
||||||
|
ContextEventResult,
|
||||||
CustomToolResultEvent,
|
CustomToolResultEvent,
|
||||||
EditToolResultEvent,
|
EditToolResultEvent,
|
||||||
ExecOptions,
|
ExecOptions,
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,13 @@
|
||||||
* Hook runner - executes hooks and manages their lifecycle.
|
* Hook runner - executes hooks and manages their lifecycle.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { AppMessage } from "@mariozechner/pi-agent-core";
|
||||||
import type { ModelRegistry } from "../model-registry.js";
|
import type { ModelRegistry } from "../model-registry.js";
|
||||||
import type { SessionManager } from "../session-manager.js";
|
import type { SessionManager } from "../session-manager.js";
|
||||||
import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js";
|
import type { AppendEntryHandler, LoadedHook, SendMessageHandler } from "./loader.js";
|
||||||
import type {
|
import type {
|
||||||
|
ContextEvent,
|
||||||
|
ContextEventResult,
|
||||||
HookError,
|
HookError,
|
||||||
HookEvent,
|
HookEvent,
|
||||||
HookEventContext,
|
HookEventContext,
|
||||||
|
|
@ -304,4 +307,43 @@ export class HookRunner {
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a context event to all hooks.
|
||||||
|
* Handlers are chained - each gets the previous handler's output (if any).
|
||||||
|
* Returns the final modified messages, or undefined if no modifications.
|
||||||
|
*/
|
||||||
|
async emitContext(messages: AppMessage[]): Promise<AppMessage[] | undefined> {
|
||||||
|
const ctx = this.createContext();
|
||||||
|
let currentMessages = messages;
|
||||||
|
let modified = false;
|
||||||
|
|
||||||
|
for (const hook of this.hooks) {
|
||||||
|
const handlers = hook.handlers.get("context");
|
||||||
|
if (!handlers || handlers.length === 0) continue;
|
||||||
|
|
||||||
|
for (const handler of handlers) {
|
||||||
|
try {
|
||||||
|
const event: ContextEvent = { type: "context", messages: currentMessages };
|
||||||
|
const timeout = createTimeout(this.timeout);
|
||||||
|
const handlerResult = await Promise.race([handler(event, ctx), timeout.promise]);
|
||||||
|
timeout.clear();
|
||||||
|
|
||||||
|
if (handlerResult && (handlerResult as ContextEventResult).messages) {
|
||||||
|
currentMessages = (handlerResult as ContextEventResult).messages!;
|
||||||
|
modified = true;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
this.emitError({
|
||||||
|
hookPath: hook.path,
|
||||||
|
event: "context",
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return modified ? currentMessages : undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,17 @@ export type SessionEvent =
|
||||||
fromHook: boolean;
|
fromHook: boolean;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event data for context event.
|
||||||
|
* Fired before messages are sent to the LLM, allowing hooks to modify context non-destructively.
|
||||||
|
* Original session messages are NOT modified - only the messages sent to the LLM are affected.
|
||||||
|
*/
|
||||||
|
export interface ContextEvent {
|
||||||
|
type: "context";
|
||||||
|
/** Messages about to be sent to the LLM */
|
||||||
|
messages: AppMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Event data for agent_start event.
|
* Event data for agent_start event.
|
||||||
* Fired when an agent loop starts (once per user prompt).
|
* Fired when an agent loop starts (once per user prompt).
|
||||||
|
|
@ -301,6 +312,7 @@ export function isLsToolResult(e: ToolResultEvent): e is LsToolResultEvent {
|
||||||
*/
|
*/
|
||||||
export type HookEvent =
|
export type HookEvent =
|
||||||
| SessionEvent
|
| SessionEvent
|
||||||
|
| ContextEvent
|
||||||
| AgentStartEvent
|
| AgentStartEvent
|
||||||
| AgentEndEvent
|
| AgentEndEvent
|
||||||
| TurnStartEvent
|
| TurnStartEvent
|
||||||
|
|
@ -312,6 +324,15 @@ export type HookEvent =
|
||||||
// Event Results
|
// Event Results
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return type for context event handlers.
|
||||||
|
* Allows hooks to modify messages before they're sent to the LLM.
|
||||||
|
*/
|
||||||
|
export interface ContextEventResult {
|
||||||
|
/** Modified messages to send instead of the original */
|
||||||
|
messages?: AppMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return type for tool_call event handlers.
|
* Return type for tool_call event handlers.
|
||||||
* Allows hooks to block tool execution.
|
* Allows hooks to block tool execution.
|
||||||
|
|
@ -417,6 +438,8 @@ export interface RegisteredCommand {
|
||||||
export interface HookAPI {
|
export interface HookAPI {
|
||||||
// biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything
|
// biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything
|
||||||
on(event: "session", handler: HookHandler<SessionEvent, SessionEventResult | void>): void;
|
on(event: "session", handler: HookHandler<SessionEvent, SessionEventResult | void>): void;
|
||||||
|
// biome-ignore lint/suspicious/noConfusingVoidType: void allows handlers to not return anything
|
||||||
|
on(event: "context", handler: HookHandler<ContextEvent, ContextEventResult | void>): void;
|
||||||
on(event: "agent_start", handler: HookHandler<AgentStartEvent>): void;
|
on(event: "agent_start", handler: HookHandler<AgentStartEvent>): void;
|
||||||
on(event: "agent_end", handler: HookHandler<AgentEndEvent>): void;
|
on(event: "agent_end", handler: HookHandler<AgentEndEvent>): void;
|
||||||
on(event: "turn_start", handler: HookHandler<TurnStartEvent>): void;
|
on(event: "turn_start", handler: HookHandler<TurnStartEvent>): void;
|
||||||
|
|
|
||||||
|
|
@ -589,6 +589,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
||||||
tools: allToolsArray,
|
tools: allToolsArray,
|
||||||
},
|
},
|
||||||
messageTransformer,
|
messageTransformer,
|
||||||
|
contextTransform: hookRunner
|
||||||
|
? async (messages) => {
|
||||||
|
return hookRunner.emitContext(messages);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
queueMode: settingsManager.getQueueMode(),
|
queueMode: settingsManager.getQueueMode(),
|
||||||
transport: new ProviderTransport({
|
transport: new ProviderTransport({
|
||||||
getApiKey: async () => {
|
getApiKey: async () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue