|
|
|
|
@@ -5,10 +5,20 @@ import {
|
|
|
|
|
streamSimple,
|
|
|
|
|
type UserMessage,
|
|
|
|
|
} from "@earendil-works/pi-ai";
|
|
|
|
|
import { Agent, type QueueMode } from "../agent.js";
|
|
|
|
|
import type { AgentEvent, AgentMessage, AgentTool, ThinkingLevel } from "../types.js";
|
|
|
|
|
import { runAgentLoop } from "../agent-loop.js";
|
|
|
|
|
import type {
|
|
|
|
|
AgentContext,
|
|
|
|
|
AgentEvent,
|
|
|
|
|
AgentLoopConfig,
|
|
|
|
|
AgentMessage,
|
|
|
|
|
AgentTool,
|
|
|
|
|
QueueMode,
|
|
|
|
|
StreamFn,
|
|
|
|
|
ThinkingLevel,
|
|
|
|
|
} from "../types.js";
|
|
|
|
|
import { collectEntriesForBranchSummary, generateBranchSummary } from "./compaction/branch-summarization.js";
|
|
|
|
|
import { compact, DEFAULT_COMPACTION_SETTINGS, prepareCompaction } from "./compaction/compaction.js";
|
|
|
|
|
import { convertToLlm } from "./messages.js";
|
|
|
|
|
import { formatPromptTemplateInvocation } from "./prompt-templates.js";
|
|
|
|
|
import { formatSkillInvocation } from "./skills.js";
|
|
|
|
|
import type {
|
|
|
|
|
@@ -35,6 +45,27 @@ function createUserMessage(text: string, images?: ImageContent[]): UserMessage {
|
|
|
|
|
return { role: "user", content, timestamp: Date.now() };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function createFailureMessage(model: Model<any>, error: unknown, aborted: boolean): AssistantMessage {
|
|
|
|
|
return {
|
|
|
|
|
role: "assistant",
|
|
|
|
|
content: [{ type: "text", text: "" }],
|
|
|
|
|
api: model.api,
|
|
|
|
|
provider: model.provider,
|
|
|
|
|
model: model.id,
|
|
|
|
|
stopReason: aborted ? "aborted" : "error",
|
|
|
|
|
errorMessage: error instanceof Error ? error.message : String(error),
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
usage: {
|
|
|
|
|
input: 0,
|
|
|
|
|
output: 0,
|
|
|
|
|
cacheRead: 0,
|
|
|
|
|
cacheWrite: 0,
|
|
|
|
|
totalTokens: 0,
|
|
|
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function cloneStreamOptions(streamOptions?: AgentHarnessStreamOptions): AgentHarnessStreamOptions {
|
|
|
|
|
return {
|
|
|
|
|
...streamOptions,
|
|
|
|
|
@@ -54,10 +85,6 @@ function mergeHeaders(...headers: Array<Record<string, string> | undefined>): Re
|
|
|
|
|
return hasHeaders ? merged : undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hasOwn(object: object, key: PropertyKey): boolean {
|
|
|
|
|
return Object.hasOwn(object, key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function applyStreamOptionsPatch(
|
|
|
|
|
base: AgentHarnessStreamOptions,
|
|
|
|
|
patch?: AgentHarnessStreamOptionsPatch,
|
|
|
|
|
@@ -65,13 +92,13 @@ function applyStreamOptionsPatch(
|
|
|
|
|
const result = cloneStreamOptions(base);
|
|
|
|
|
if (!patch) return result;
|
|
|
|
|
|
|
|
|
|
if (hasOwn(patch, "transport")) result.transport = patch.transport;
|
|
|
|
|
if (hasOwn(patch, "timeoutMs")) result.timeoutMs = patch.timeoutMs;
|
|
|
|
|
if (hasOwn(patch, "maxRetries")) result.maxRetries = patch.maxRetries;
|
|
|
|
|
if (hasOwn(patch, "maxRetryDelayMs")) result.maxRetryDelayMs = patch.maxRetryDelayMs;
|
|
|
|
|
if (hasOwn(patch, "cacheRetention")) result.cacheRetention = patch.cacheRetention;
|
|
|
|
|
if (Object.hasOwn(patch, "transport")) result.transport = patch.transport;
|
|
|
|
|
if (Object.hasOwn(patch, "timeoutMs")) result.timeoutMs = patch.timeoutMs;
|
|
|
|
|
if (Object.hasOwn(patch, "maxRetries")) result.maxRetries = patch.maxRetries;
|
|
|
|
|
if (Object.hasOwn(patch, "maxRetryDelayMs")) result.maxRetryDelayMs = patch.maxRetryDelayMs;
|
|
|
|
|
if (Object.hasOwn(patch, "cacheRetention")) result.cacheRetention = patch.cacheRetention;
|
|
|
|
|
|
|
|
|
|
if (hasOwn(patch, "headers")) {
|
|
|
|
|
if (Object.hasOwn(patch, "headers")) {
|
|
|
|
|
if (patch.headers === undefined) {
|
|
|
|
|
result.headers = undefined;
|
|
|
|
|
} else {
|
|
|
|
|
@@ -84,7 +111,7 @@ function applyStreamOptionsPatch(
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (hasOwn(patch, "metadata")) {
|
|
|
|
|
if (Object.hasOwn(patch, "metadata")) {
|
|
|
|
|
if (patch.metadata === undefined) {
|
|
|
|
|
result.metadata = undefined;
|
|
|
|
|
} else {
|
|
|
|
|
@@ -100,6 +127,10 @@ function applyStreamOptionsPatch(
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const SUBSCRIBER_EVENT_TYPE = "*";
|
|
|
|
|
|
|
|
|
|
type AgentHarnessHandler = (event: any, signal?: AbortSignal) => Promise<any> | any;
|
|
|
|
|
|
|
|
|
|
interface AgentHarnessTurnState<
|
|
|
|
|
TSkill extends Skill = Skill,
|
|
|
|
|
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
|
|
|
|
@@ -121,72 +152,28 @@ export class AgentHarness<
|
|
|
|
|
TPromptTemplate extends PromptTemplate = PromptTemplate,
|
|
|
|
|
TTool extends AgentTool = AgentTool,
|
|
|
|
|
> {
|
|
|
|
|
readonly agent: Agent;
|
|
|
|
|
readonly env: ExecutionEnv;
|
|
|
|
|
private session: Session;
|
|
|
|
|
private phase: AgentHarnessPhase = "idle";
|
|
|
|
|
private runAbortController?: AbortController;
|
|
|
|
|
private runPromise?: Promise<void>;
|
|
|
|
|
private pendingSessionWrites: PendingSessionWrite[] = [];
|
|
|
|
|
private model: Model<any>;
|
|
|
|
|
private thinkingLevel: ThinkingLevel;
|
|
|
|
|
private activeToolNames: string[];
|
|
|
|
|
private nextTurnQueue: AgentMessage[] = [];
|
|
|
|
|
private phase: AgentHarnessPhase = "idle";
|
|
|
|
|
private steerQueue: UserMessage[] = [];
|
|
|
|
|
private followUpQueue: UserMessage[] = [];
|
|
|
|
|
private pendingSessionWrites: PendingSessionWrite[] = [];
|
|
|
|
|
private resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
|
|
|
|
private streamOptions: AgentHarnessStreamOptions;
|
|
|
|
|
private appliedStreamOptions: AgentHarnessStreamOptions = {};
|
|
|
|
|
private appliedSessionId?: string;
|
|
|
|
|
private systemPrompt: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>["systemPrompt"];
|
|
|
|
|
private streamOptions: AgentHarnessStreamOptions;
|
|
|
|
|
private getApiKeyAndHeaders?: AgentHarnessOptions["getApiKeyAndHeaders"];
|
|
|
|
|
private resources: AgentHarnessResources<TSkill, TPromptTemplate>;
|
|
|
|
|
private tools = new Map<string, TTool>();
|
|
|
|
|
private listeners = new Set<
|
|
|
|
|
(event: AgentHarnessEvent<TSkill, TPromptTemplate>, signal?: AbortSignal) => Promise<void> | void
|
|
|
|
|
>();
|
|
|
|
|
private hooks = new Map<keyof AgentHarnessEventResultMap, Set<(event: any) => Promise<any> | any>>();
|
|
|
|
|
private activeToolNames: string[];
|
|
|
|
|
private steerQueue: UserMessage[] = [];
|
|
|
|
|
private steeringQueueMode: QueueMode;
|
|
|
|
|
private followUpQueue: UserMessage[] = [];
|
|
|
|
|
private followUpQueueMode: QueueMode;
|
|
|
|
|
private nextTurnQueue: AgentMessage[] = [];
|
|
|
|
|
private handlers = new Map<string, Set<AgentHarnessHandler>>();
|
|
|
|
|
|
|
|
|
|
constructor(options: AgentHarnessOptions<TSkill, TPromptTemplate, TTool>) {
|
|
|
|
|
this.agent = new Agent({
|
|
|
|
|
initialState: {
|
|
|
|
|
model: options.model,
|
|
|
|
|
thinkingLevel: options.thinkingLevel,
|
|
|
|
|
tools: options.tools ?? [],
|
|
|
|
|
},
|
|
|
|
|
streamFn: async (model, context, streamOptions) => {
|
|
|
|
|
const auth = await this.getApiKeyAndHeaders?.(model);
|
|
|
|
|
const snapshotOptions: AgentHarnessStreamOptions = {
|
|
|
|
|
...this.appliedStreamOptions,
|
|
|
|
|
headers: mergeHeaders(this.appliedStreamOptions.headers, auth?.headers),
|
|
|
|
|
};
|
|
|
|
|
const requestOptions = await this.emitBeforeProviderRequest(
|
|
|
|
|
model,
|
|
|
|
|
this.appliedSessionId ?? "",
|
|
|
|
|
snapshotOptions,
|
|
|
|
|
);
|
|
|
|
|
return streamSimple(model, context, {
|
|
|
|
|
cacheRetention: requestOptions.cacheRetention,
|
|
|
|
|
headers: requestOptions.headers,
|
|
|
|
|
maxRetries: requestOptions.maxRetries,
|
|
|
|
|
maxRetryDelayMs: requestOptions.maxRetryDelayMs,
|
|
|
|
|
metadata: requestOptions.metadata,
|
|
|
|
|
onPayload: async (payload) => await this.emitBeforeProviderPayload(model, payload),
|
|
|
|
|
onResponse: async (response) => {
|
|
|
|
|
const headers = { ...(response.headers as Record<string, string>) };
|
|
|
|
|
await this.emitOwn(
|
|
|
|
|
{ type: "after_provider_response", status: response.status, headers },
|
|
|
|
|
this.agent.signal,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
reasoning: streamOptions?.reasoning,
|
|
|
|
|
signal: streamOptions?.signal,
|
|
|
|
|
sessionId: this.appliedSessionId,
|
|
|
|
|
timeoutMs: requestOptions.timeoutMs,
|
|
|
|
|
transport: requestOptions.transport,
|
|
|
|
|
apiKey: auth?.apiKey,
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
steeringMode: options.steeringMode,
|
|
|
|
|
followUpMode: options.followUpMode,
|
|
|
|
|
});
|
|
|
|
|
this.env = options.env;
|
|
|
|
|
this.session = options.session;
|
|
|
|
|
this.resources = options.resources ?? {};
|
|
|
|
|
@@ -197,64 +184,24 @@ export class AgentHarness<
|
|
|
|
|
this.tools.set(tool.name, tool);
|
|
|
|
|
}
|
|
|
|
|
this.model = options.model;
|
|
|
|
|
this.thinkingLevel = options.thinkingLevel ?? this.agent.state.thinkingLevel;
|
|
|
|
|
this.thinkingLevel = options.thinkingLevel ?? "off";
|
|
|
|
|
this.activeToolNames = options.activeToolNames ?? (options.tools ?? []).map((tool) => tool.name);
|
|
|
|
|
this.agent.state.model = this.model;
|
|
|
|
|
this.agent.state.thinkingLevel = this.thinkingLevel;
|
|
|
|
|
this.agent.transformContext = async (messages) => {
|
|
|
|
|
const result = await this.emitHook({ type: "context", messages: [...messages] });
|
|
|
|
|
return result?.messages ?? messages;
|
|
|
|
|
};
|
|
|
|
|
this.agent.beforeToolCall = async ({ toolCall, args }) => {
|
|
|
|
|
const result = await this.emitHook({
|
|
|
|
|
type: "tool_call",
|
|
|
|
|
toolCallId: toolCall.id,
|
|
|
|
|
toolName: toolCall.name,
|
|
|
|
|
input: args as Record<string, unknown>,
|
|
|
|
|
});
|
|
|
|
|
return result ? { block: result.block, reason: result.reason } : undefined;
|
|
|
|
|
};
|
|
|
|
|
this.agent.afterToolCall = async ({ toolCall, args, result, isError }) => {
|
|
|
|
|
const patch = await this.emitHook({
|
|
|
|
|
type: "tool_result",
|
|
|
|
|
toolCallId: toolCall.id,
|
|
|
|
|
toolName: toolCall.name,
|
|
|
|
|
input: args as Record<string, unknown>,
|
|
|
|
|
content: result.content,
|
|
|
|
|
details: result.details,
|
|
|
|
|
isError,
|
|
|
|
|
});
|
|
|
|
|
return patch
|
|
|
|
|
? { content: patch.content, details: patch.details, isError: patch.isError, terminate: patch.terminate }
|
|
|
|
|
: undefined;
|
|
|
|
|
};
|
|
|
|
|
this.agent.prepareNextTurn = async () => {
|
|
|
|
|
await this.flushPendingSessionWrites();
|
|
|
|
|
const turnState = await this.createTurnState();
|
|
|
|
|
this.applyTurnState(turnState);
|
|
|
|
|
return {
|
|
|
|
|
context: {
|
|
|
|
|
systemPrompt: turnState.systemPrompt,
|
|
|
|
|
messages: turnState.messages.slice(),
|
|
|
|
|
tools: turnState.activeTools.slice(),
|
|
|
|
|
},
|
|
|
|
|
model: turnState.model,
|
|
|
|
|
thinkingLevel: turnState.thinkingLevel,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
this.agent.subscribe(async (event, signal) => {
|
|
|
|
|
await this.handleAgentEvent(event, signal);
|
|
|
|
|
});
|
|
|
|
|
this.steeringQueueMode = options.steeringMode ?? "one-at-a-time";
|
|
|
|
|
this.followUpQueueMode = options.followUpMode ?? "one-at-a-time";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getHandlers(type: string): Set<AgentHarnessHandler> | undefined {
|
|
|
|
|
return this.handlers.get(type);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async emitOwn(event: AgentHarnessOwnEvent<TSkill, TPromptTemplate>, signal?: AbortSignal): Promise<void> {
|
|
|
|
|
for (const listener of this.listeners) {
|
|
|
|
|
for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) {
|
|
|
|
|
await listener(event, signal);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async emitAny(event: AgentHarnessEvent<TSkill, TPromptTemplate>, signal?: AbortSignal): Promise<void> {
|
|
|
|
|
for (const listener of this.listeners) {
|
|
|
|
|
for (const listener of this.getHandlers(SUBSCRIBER_EVENT_TYPE) ?? []) {
|
|
|
|
|
await listener(event, signal);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -262,7 +209,7 @@ export class AgentHarness<
|
|
|
|
|
private async emitHook<TType extends keyof AgentHarnessEventResultMap>(
|
|
|
|
|
event: Extract<AgentHarnessOwnEvent, { type: TType }>,
|
|
|
|
|
): Promise<AgentHarnessEventResultMap[TType] | undefined> {
|
|
|
|
|
const handlers = this.hooks.get(event.type as TType);
|
|
|
|
|
const handlers = this.getHandlers(event.type as TType);
|
|
|
|
|
if (!handlers || handlers.size === 0) return undefined;
|
|
|
|
|
let lastResult: AgentHarnessEventResultMap[TType] | undefined;
|
|
|
|
|
for (const handler of handlers) {
|
|
|
|
|
@@ -279,7 +226,7 @@ export class AgentHarness<
|
|
|
|
|
sessionId: string,
|
|
|
|
|
streamOptions: AgentHarnessStreamOptions,
|
|
|
|
|
): Promise<AgentHarnessStreamOptions> {
|
|
|
|
|
const handlers = this.hooks.get("before_provider_request");
|
|
|
|
|
const handlers = this.getHandlers("before_provider_request");
|
|
|
|
|
let current = cloneStreamOptions(streamOptions);
|
|
|
|
|
if (!handlers || handlers.size === 0) return current;
|
|
|
|
|
for (const handler of handlers) {
|
|
|
|
|
@@ -297,7 +244,7 @@ export class AgentHarness<
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async emitBeforeProviderPayload(model: Model<any>, payload: unknown): Promise<unknown> {
|
|
|
|
|
const handlers = this.hooks.get("before_provider_payload");
|
|
|
|
|
const handlers = this.getHandlers("before_provider_payload");
|
|
|
|
|
let current = payload;
|
|
|
|
|
if (!handlers || handlers.size === 0) return current;
|
|
|
|
|
for (const handler of handlers) {
|
|
|
|
|
@@ -318,6 +265,17 @@ export class AgentHarness<
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private startRunPromise(): () => void {
|
|
|
|
|
let finish = () => {};
|
|
|
|
|
this.runPromise = new Promise<void>((resolve) => {
|
|
|
|
|
finish = resolve;
|
|
|
|
|
});
|
|
|
|
|
return () => {
|
|
|
|
|
this.runPromise = undefined;
|
|
|
|
|
finish();
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async createTurnState(): Promise<AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>> {
|
|
|
|
|
const context = await this.session.buildContext();
|
|
|
|
|
const resources = this.getResources();
|
|
|
|
|
@@ -352,14 +310,105 @@ export class AgentHarness<
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private applyTurnState(turnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>): void {
|
|
|
|
|
this.agent.state.messages = turnState.messages;
|
|
|
|
|
this.appliedStreamOptions = cloneStreamOptions(turnState.streamOptions);
|
|
|
|
|
this.appliedSessionId = turnState.sessionId;
|
|
|
|
|
this.agent.state.systemPrompt = turnState.systemPrompt;
|
|
|
|
|
this.agent.state.model = turnState.model;
|
|
|
|
|
this.agent.state.thinkingLevel = turnState.thinkingLevel;
|
|
|
|
|
this.agent.state.tools = turnState.activeTools;
|
|
|
|
|
private createContext(
|
|
|
|
|
turnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>,
|
|
|
|
|
systemPrompt?: string,
|
|
|
|
|
): AgentContext {
|
|
|
|
|
return {
|
|
|
|
|
systemPrompt: systemPrompt ?? turnState.systemPrompt,
|
|
|
|
|
messages: turnState.messages.slice(),
|
|
|
|
|
tools: turnState.activeTools.slice(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createStreamFn(getTurnState: () => AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>): StreamFn {
|
|
|
|
|
return async (model, context, streamOptions) => {
|
|
|
|
|
const turnState = getTurnState();
|
|
|
|
|
const auth = await this.getApiKeyAndHeaders?.(model);
|
|
|
|
|
const snapshotOptions: AgentHarnessStreamOptions = {
|
|
|
|
|
...turnState.streamOptions,
|
|
|
|
|
headers: mergeHeaders(turnState.streamOptions.headers, auth?.headers),
|
|
|
|
|
};
|
|
|
|
|
const requestOptions = await this.emitBeforeProviderRequest(model, turnState.sessionId, snapshotOptions);
|
|
|
|
|
return streamSimple(model, context, {
|
|
|
|
|
cacheRetention: requestOptions.cacheRetention,
|
|
|
|
|
headers: requestOptions.headers,
|
|
|
|
|
maxRetries: requestOptions.maxRetries,
|
|
|
|
|
maxRetryDelayMs: requestOptions.maxRetryDelayMs,
|
|
|
|
|
metadata: requestOptions.metadata,
|
|
|
|
|
onPayload: async (payload) => await this.emitBeforeProviderPayload(model, payload),
|
|
|
|
|
onResponse: async (response) => {
|
|
|
|
|
const headers = { ...(response.headers as Record<string, string>) };
|
|
|
|
|
await this.emitOwn(
|
|
|
|
|
{ type: "after_provider_response", status: response.status, headers },
|
|
|
|
|
streamOptions?.signal,
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
reasoning: streamOptions?.reasoning,
|
|
|
|
|
signal: streamOptions?.signal,
|
|
|
|
|
sessionId: turnState.sessionId,
|
|
|
|
|
timeoutMs: requestOptions.timeoutMs,
|
|
|
|
|
transport: requestOptions.transport,
|
|
|
|
|
apiKey: auth?.apiKey,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async drainQueuedMessages(queue: AgentMessage[], mode: QueueMode): Promise<AgentMessage[]> {
|
|
|
|
|
const messages = mode === "all" ? queue.splice(0) : queue.splice(0, 1);
|
|
|
|
|
if (messages.length > 0) await this.emitQueueUpdate();
|
|
|
|
|
return messages;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private createLoopConfig(
|
|
|
|
|
getTurnState: () => AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>,
|
|
|
|
|
setTurnState: (turnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>) => void,
|
|
|
|
|
): AgentLoopConfig {
|
|
|
|
|
const turnState = getTurnState();
|
|
|
|
|
return {
|
|
|
|
|
model: turnState.model,
|
|
|
|
|
reasoning: turnState.thinkingLevel === "off" ? undefined : turnState.thinkingLevel,
|
|
|
|
|
convertToLlm,
|
|
|
|
|
transformContext: async (messages) => {
|
|
|
|
|
const result = await this.emitHook({ type: "context", messages: [...messages] });
|
|
|
|
|
return result?.messages ?? messages;
|
|
|
|
|
},
|
|
|
|
|
beforeToolCall: async ({ toolCall, args }) => {
|
|
|
|
|
const result = await this.emitHook({
|
|
|
|
|
type: "tool_call",
|
|
|
|
|
toolCallId: toolCall.id,
|
|
|
|
|
toolName: toolCall.name,
|
|
|
|
|
input: args as Record<string, unknown>,
|
|
|
|
|
});
|
|
|
|
|
return result ? { block: result.block, reason: result.reason } : undefined;
|
|
|
|
|
},
|
|
|
|
|
afterToolCall: async ({ toolCall, args, result, isError }) => {
|
|
|
|
|
const patch = await this.emitHook({
|
|
|
|
|
type: "tool_result",
|
|
|
|
|
toolCallId: toolCall.id,
|
|
|
|
|
toolName: toolCall.name,
|
|
|
|
|
input: args as Record<string, unknown>,
|
|
|
|
|
content: result.content,
|
|
|
|
|
details: result.details,
|
|
|
|
|
isError,
|
|
|
|
|
});
|
|
|
|
|
return patch
|
|
|
|
|
? { content: patch.content, details: patch.details, isError: patch.isError, terminate: patch.terminate }
|
|
|
|
|
: undefined;
|
|
|
|
|
},
|
|
|
|
|
prepareNextTurn: async () => {
|
|
|
|
|
await this.flushPendingSessionWrites();
|
|
|
|
|
const nextTurnState = await this.createTurnState();
|
|
|
|
|
setTurnState(nextTurnState);
|
|
|
|
|
return {
|
|
|
|
|
context: this.createContext(nextTurnState),
|
|
|
|
|
model: nextTurnState.model,
|
|
|
|
|
thinkingLevel: nextTurnState.thinkingLevel,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
getSteeringMessages: async () => this.drainQueuedMessages(this.steerQueue, this.steeringQueueMode),
|
|
|
|
|
getFollowUpMessages: async () => this.drainQueuedMessages(this.followUpQueue, this.followUpQueueMode),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private validateToolNames(toolNames: string[]): void {
|
|
|
|
|
@@ -391,19 +440,6 @@ export class AgentHarness<
|
|
|
|
|
|
|
|
|
|
private async handleAgentEvent(event: AgentEvent, signal?: AbortSignal): Promise<void> {
|
|
|
|
|
await this.emitAny(event, signal);
|
|
|
|
|
if (event.type === "message_start" && event.message.role === "user") {
|
|
|
|
|
const steerIndex = this.steerQueue.indexOf(event.message);
|
|
|
|
|
if (steerIndex !== -1) {
|
|
|
|
|
this.steerQueue.splice(steerIndex, 1);
|
|
|
|
|
await this.emitQueueUpdate();
|
|
|
|
|
} else {
|
|
|
|
|
const followUpIndex = this.followUpQueue.indexOf(event.message);
|
|
|
|
|
if (followUpIndex !== -1) {
|
|
|
|
|
this.followUpQueue.splice(followUpIndex, 1);
|
|
|
|
|
await this.emitQueueUpdate();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (event.type === "message_end") {
|
|
|
|
|
await this.session.appendMessage(event.message);
|
|
|
|
|
}
|
|
|
|
|
@@ -422,13 +458,26 @@ export class AgentHarness<
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async emitRunFailure(
|
|
|
|
|
model: Model<any>,
|
|
|
|
|
error: unknown,
|
|
|
|
|
aborted: boolean,
|
|
|
|
|
signal: AbortSignal,
|
|
|
|
|
): Promise<AgentMessage[]> {
|
|
|
|
|
const failureMessage = createFailureMessage(model, error, aborted);
|
|
|
|
|
await this.handleAgentEvent({ type: "message_start", message: failureMessage }, signal);
|
|
|
|
|
await this.handleAgentEvent({ type: "message_end", message: failureMessage }, signal);
|
|
|
|
|
await this.handleAgentEvent({ type: "turn_end", message: failureMessage, toolResults: [] }, signal);
|
|
|
|
|
await this.handleAgentEvent({ type: "agent_end", messages: [failureMessage] }, signal);
|
|
|
|
|
return [failureMessage];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async executeTurn(
|
|
|
|
|
turnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>,
|
|
|
|
|
text: string,
|
|
|
|
|
options?: { images?: ImageContent[] },
|
|
|
|
|
): Promise<AssistantMessage> {
|
|
|
|
|
this.applyTurnState(turnState);
|
|
|
|
|
const beforeLength = this.agent.state.messages.length;
|
|
|
|
|
let activeTurnState = turnState;
|
|
|
|
|
let messages: AgentMessage[] = [createUserMessage(text, options?.images)];
|
|
|
|
|
if (this.nextTurnQueue.length > 0) {
|
|
|
|
|
messages = [...this.nextTurnQueue, messages[0]!];
|
|
|
|
|
@@ -442,41 +491,70 @@ export class AgentHarness<
|
|
|
|
|
systemPrompt: turnState.systemPrompt,
|
|
|
|
|
resources: turnState.resources,
|
|
|
|
|
});
|
|
|
|
|
if (beforeResult?.messages) messages = [...beforeResult.messages, ...messages];
|
|
|
|
|
if (beforeResult?.systemPrompt) this.agent.state.systemPrompt = beforeResult.systemPrompt;
|
|
|
|
|
if (beforeResult?.messages) messages = [...messages, ...beforeResult.messages];
|
|
|
|
|
|
|
|
|
|
const abortController = new AbortController();
|
|
|
|
|
const getTurnState = () => activeTurnState;
|
|
|
|
|
const setTurnState = (nextTurnState: AgentHarnessTurnState<TSkill, TPromptTemplate, TTool>) => {
|
|
|
|
|
activeTurnState = nextTurnState;
|
|
|
|
|
};
|
|
|
|
|
this.runAbortController = abortController;
|
|
|
|
|
const runResultPromise = (async () => {
|
|
|
|
|
try {
|
|
|
|
|
return await runAgentLoop(
|
|
|
|
|
messages,
|
|
|
|
|
this.createContext(turnState, beforeResult?.systemPrompt),
|
|
|
|
|
this.createLoopConfig(getTurnState, setTurnState),
|
|
|
|
|
(event) => this.handleAgentEvent(event, abortController.signal),
|
|
|
|
|
abortController.signal,
|
|
|
|
|
this.createStreamFn(getTurnState),
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return await this.emitRunFailure(
|
|
|
|
|
activeTurnState.model,
|
|
|
|
|
error,
|
|
|
|
|
abortController.signal.aborted,
|
|
|
|
|
abortController.signal,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
})();
|
|
|
|
|
try {
|
|
|
|
|
await this.agent.prompt(messages);
|
|
|
|
|
const newMessages = await runResultPromise;
|
|
|
|
|
for (let i = newMessages.length - 1; i >= 0; i--) {
|
|
|
|
|
const message = newMessages[i]!;
|
|
|
|
|
if (message.role === "assistant") {
|
|
|
|
|
return message;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
throw new Error("AgentHarness prompt completed without an assistant message");
|
|
|
|
|
} finally {
|
|
|
|
|
await this.flushPendingSessionWrites();
|
|
|
|
|
}
|
|
|
|
|
let response: AssistantMessage | undefined;
|
|
|
|
|
const newMessages = this.agent.state.messages.slice(beforeLength);
|
|
|
|
|
for (let i = newMessages.length - 1; i >= 0; i--) {
|
|
|
|
|
const message = newMessages[i]!;
|
|
|
|
|
if (message.role === "assistant") {
|
|
|
|
|
response = message;
|
|
|
|
|
break;
|
|
|
|
|
try {
|
|
|
|
|
await this.flushPendingSessionWrites();
|
|
|
|
|
} finally {
|
|
|
|
|
this.runAbortController = undefined;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (!response) throw new Error("AgentHarness prompt completed without an assistant message");
|
|
|
|
|
return response;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async prompt(text: string, options?: { images?: ImageContent[] }): Promise<AssistantMessage> {
|
|
|
|
|
if (this.phase !== "idle") throw new Error("AgentHarness is busy");
|
|
|
|
|
this.phase = "turn";
|
|
|
|
|
const finishRunPromise = this.startRunPromise();
|
|
|
|
|
try {
|
|
|
|
|
const turnState = await this.createTurnState();
|
|
|
|
|
return await this.executeTurn(turnState, text, options);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.phase = "idle";
|
|
|
|
|
throw error;
|
|
|
|
|
} finally {
|
|
|
|
|
finishRunPromise();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async skill(name: string, additionalInstructions?: string): Promise<AssistantMessage> {
|
|
|
|
|
if (this.phase !== "idle") throw new Error("AgentHarness is busy");
|
|
|
|
|
this.phase = "turn";
|
|
|
|
|
const finishRunPromise = this.startRunPromise();
|
|
|
|
|
try {
|
|
|
|
|
const turnState = await this.createTurnState();
|
|
|
|
|
const skill = (turnState.resources.skills ?? []).find((candidate) => candidate.name === name);
|
|
|
|
|
@@ -485,12 +563,15 @@ export class AgentHarness<
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.phase = "idle";
|
|
|
|
|
throw error;
|
|
|
|
|
} finally {
|
|
|
|
|
finishRunPromise();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async promptFromTemplate(name: string, args: string[] = []): Promise<AssistantMessage> {
|
|
|
|
|
if (this.phase !== "idle") throw new Error("AgentHarness is busy");
|
|
|
|
|
this.phase = "turn";
|
|
|
|
|
const finishRunPromise = this.startRunPromise();
|
|
|
|
|
try {
|
|
|
|
|
const turnState = await this.createTurnState();
|
|
|
|
|
const template = (turnState.resources.promptTemplates ?? []).find((candidate) => candidate.name === name);
|
|
|
|
|
@@ -499,22 +580,20 @@ export class AgentHarness<
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.phase = "idle";
|
|
|
|
|
throw error;
|
|
|
|
|
} finally {
|
|
|
|
|
finishRunPromise();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
steer(text: string, options?: { images?: ImageContent[] }): void {
|
|
|
|
|
if (this.phase === "idle") throw new Error("Cannot steer while idle");
|
|
|
|
|
const message = createUserMessage(text, options?.images);
|
|
|
|
|
this.steerQueue.push(message);
|
|
|
|
|
this.agent.steer(message);
|
|
|
|
|
this.steerQueue.push(createUserMessage(text, options?.images));
|
|
|
|
|
void this.emitQueueUpdate();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
followUp(text: string, options?: { images?: ImageContent[] }): void {
|
|
|
|
|
if (this.phase === "idle") throw new Error("Cannot follow up while idle");
|
|
|
|
|
const message = createUserMessage(text, options?.images);
|
|
|
|
|
this.followUpQueue.push(message);
|
|
|
|
|
this.agent.followUp(message);
|
|
|
|
|
this.followUpQueue.push(createUserMessage(text, options?.images));
|
|
|
|
|
void this.emitQueueUpdate();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -690,11 +769,18 @@ export class AgentHarness<
|
|
|
|
|
return { cancelled: false, editorText, summaryEntry };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getModel(): Model<any> {
|
|
|
|
|
return this.model;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getThinkingLevel(): ThinkingLevel {
|
|
|
|
|
return this.thinkingLevel;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async setModel(model: Model<any>): Promise<void> {
|
|
|
|
|
const previousModel = this.model;
|
|
|
|
|
this.model = model;
|
|
|
|
|
if (this.phase === "idle") {
|
|
|
|
|
this.agent.state.model = model;
|
|
|
|
|
await this.session.appendModelChange(model.provider, model.id);
|
|
|
|
|
} else {
|
|
|
|
|
this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id });
|
|
|
|
|
@@ -706,7 +792,6 @@ export class AgentHarness<
|
|
|
|
|
const previousLevel = this.thinkingLevel;
|
|
|
|
|
this.thinkingLevel = level;
|
|
|
|
|
if (this.phase === "idle") {
|
|
|
|
|
this.agent.state.thinkingLevel = level;
|
|
|
|
|
await this.session.appendThinkingLevelChange(level);
|
|
|
|
|
} else {
|
|
|
|
|
this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level });
|
|
|
|
|
@@ -717,25 +802,22 @@ export class AgentHarness<
|
|
|
|
|
async setActiveTools(toolNames: string[]): Promise<void> {
|
|
|
|
|
this.validateToolNames(toolNames);
|
|
|
|
|
this.activeToolNames = [...toolNames];
|
|
|
|
|
if (this.phase === "idle") {
|
|
|
|
|
this.agent.state.tools = this.activeToolNames.map((name) => this.tools.get(name)!);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get steeringMode(): QueueMode {
|
|
|
|
|
return this.agent.steeringMode;
|
|
|
|
|
getSteeringMode(): QueueMode {
|
|
|
|
|
return this.steeringQueueMode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set steeringMode(mode: QueueMode) {
|
|
|
|
|
this.agent.steeringMode = mode;
|
|
|
|
|
setSteeringMode(mode: QueueMode): void {
|
|
|
|
|
this.steeringQueueMode = mode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
get followUpMode(): QueueMode {
|
|
|
|
|
return this.agent.followUpMode;
|
|
|
|
|
getFollowUpMode(): QueueMode {
|
|
|
|
|
return this.followUpQueueMode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
set followUpMode(mode: QueueMode) {
|
|
|
|
|
this.agent.followUpMode = mode;
|
|
|
|
|
setFollowUpMode(mode: QueueMode): void {
|
|
|
|
|
this.followUpQueueMode = mode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getResources(): AgentHarnessResources<TSkill, TPromptTemplate> {
|
|
|
|
|
@@ -770,9 +852,6 @@ export class AgentHarness<
|
|
|
|
|
} else {
|
|
|
|
|
this.validateToolNames(this.activeToolNames);
|
|
|
|
|
}
|
|
|
|
|
if (this.phase === "idle") {
|
|
|
|
|
this.agent.state.tools = this.activeToolNames.map((name) => this.tools.get(name)!);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async abort(): Promise<AbortResult> {
|
|
|
|
|
@@ -780,23 +859,27 @@ export class AgentHarness<
|
|
|
|
|
const clearedFollowUp = [...this.followUpQueue];
|
|
|
|
|
this.steerQueue = [];
|
|
|
|
|
this.followUpQueue = [];
|
|
|
|
|
this.agent.clearAllQueues();
|
|
|
|
|
await this.emitQueueUpdate();
|
|
|
|
|
this.agent.abort();
|
|
|
|
|
await this.agent.waitForIdle();
|
|
|
|
|
this.runAbortController?.abort();
|
|
|
|
|
await this.waitForIdle();
|
|
|
|
|
await this.emitOwn({ type: "abort", clearedSteer, clearedFollowUp });
|
|
|
|
|
return { clearedSteer, clearedFollowUp };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async waitForIdle(): Promise<void> {
|
|
|
|
|
await this.agent.waitForIdle();
|
|
|
|
|
await this.runPromise;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
subscribe(
|
|
|
|
|
listener: (event: AgentHarnessEvent<TSkill, TPromptTemplate>, signal?: AbortSignal) => Promise<void> | void,
|
|
|
|
|
): () => void {
|
|
|
|
|
this.listeners.add(listener);
|
|
|
|
|
return () => this.listeners.delete(listener);
|
|
|
|
|
let handlers = this.handlers.get(SUBSCRIBER_EVENT_TYPE);
|
|
|
|
|
if (!handlers) {
|
|
|
|
|
handlers = new Set();
|
|
|
|
|
this.handlers.set(SUBSCRIBER_EVENT_TYPE, handlers);
|
|
|
|
|
}
|
|
|
|
|
handlers.add(listener as AgentHarnessHandler);
|
|
|
|
|
return () => handlers!.delete(listener as AgentHarnessHandler);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
on<TType extends keyof AgentHarnessEventResultMap>(
|
|
|
|
|
@@ -805,12 +888,12 @@ export class AgentHarness<
|
|
|
|
|
event: Extract<AgentHarnessOwnEvent, { type: TType }>,
|
|
|
|
|
) => Promise<AgentHarnessEventResultMap[TType]> | AgentHarnessEventResultMap[TType],
|
|
|
|
|
): () => void {
|
|
|
|
|
let handlers = this.hooks.get(type);
|
|
|
|
|
let handlers = this.handlers.get(type);
|
|
|
|
|
if (!handlers) {
|
|
|
|
|
handlers = new Set();
|
|
|
|
|
this.hooks.set(type, handlers);
|
|
|
|
|
this.handlers.set(type, handlers);
|
|
|
|
|
}
|
|
|
|
|
handlers.add(handler as any);
|
|
|
|
|
return () => handlers!.delete(handler as any);
|
|
|
|
|
handlers.add(handler as AgentHarnessHandler);
|
|
|
|
|
return () => handlers!.delete(handler as AgentHarnessHandler);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|