Finish harness tool registry semantics

This commit is contained in:
Mario Zechner
2026-05-28 12:34:02 +02:00
parent 458a7bc27c
commit b63d26332f
8 changed files with 272 additions and 41 deletions

View File

@@ -86,6 +86,16 @@ function mergeHeaders(...headers: Array<Record<string, string> | undefined>): Re
return hasHeaders ? merged : undefined;
}
function findDuplicateNames(names: string[]): string[] {
const seen = new Set<string>();
const duplicates = new Set<string>();
for (const name of names) {
if (seen.has(name)) duplicates.add(name);
seen.add(name);
}
return [...duplicates];
}
function applyStreamOptionsPatch(
base: AgentHarnessStreamOptions,
patch?: AgentHarnessStreamOptionsPatch,
@@ -194,12 +204,20 @@ export class AgentHarness<
this.streamOptions = cloneStreamOptions(options.streamOptions);
this.systemPrompt = options.systemPrompt;
this.getApiKeyAndHeaders = options.getApiKeyAndHeaders;
this.validateUniqueNames(
(options.tools ?? []).map((tool) => tool.name),
"Duplicate tool name(s)",
);
for (const tool of options.tools ?? []) {
this.tools.set(tool.name, tool);
}
this.model = options.model;
this.thinkingLevel = options.thinkingLevel ?? "off";
this.activeToolNames = options.activeToolNames ?? (options.tools ?? []).map((tool) => tool.name);
this.activeToolNames = options.activeToolNames
? [...options.activeToolNames]
: (options.tools ?? []).map((tool) => tool.name);
this.validateUniqueNames(this.activeToolNames, "Duplicate active tool name(s)");
this.validateToolNames(this.activeToolNames);
this.steeringQueueMode = options.steeringMode ?? "one-at-a-time";
this.followUpQueueMode = options.followUpMode ?? "one-at-a-time";
}
@@ -451,7 +469,14 @@ export class AgentHarness<
};
}
private validateUniqueNames(names: string[], message: string): void {
const duplicates = findDuplicateNames(names);
if (duplicates.length > 0)
throw new AgentHarnessError("invalid_argument", `${message}: ${duplicates.join(", ")}`);
}
private validateToolNames(toolNames: string[], tools: Map<string, TTool> = this.tools): void {
this.validateUniqueNames(toolNames, "Duplicate active tool name(s)");
const missing = toolNames.filter((name) => !tools.has(name));
if (missing.length > 0) throw new AgentHarnessError("invalid_argument", `Unknown tool(s): ${missing.join(", ")}`);
}
@@ -465,6 +490,8 @@ export class AgentHarness<
await this.session.appendModelChange(write.provider, write.modelId);
} else if (write.type === "thinking_level_change") {
await this.session.appendThinkingLevelChange(write.thinkingLevel);
} else if (write.type === "active_tools_change") {
await this.session.appendActiveToolsChange(write.activeToolNames);
} else if (write.type === "custom") {
await this.session.appendCustomEntry(write.customType, write.data);
} else if (write.type === "custom_message") {
@@ -838,10 +865,6 @@ export class AgentHarness<
return this.model;
}
getThinkingLevel(): ThinkingLevel {
return this.thinkingLevel;
}
async setModel(model: Model<any>): Promise<void> {
try {
const previousModel = this.model;
@@ -851,12 +874,16 @@ export class AgentHarness<
this.pendingSessionWrites.push({ type: "model_change", provider: model.provider, modelId: model.id });
}
this.model = model;
await this.emitOwn({ type: "model_select", model, previousModel, source: "set" });
await this.emitOwn({ type: "model_update", model, previousModel, source: "set" });
} catch (error) {
throw normalizeHarnessError(error, "session");
}
}
getThinkingLevel(): ThinkingLevel {
return this.thinkingLevel;
}
async setThinkingLevel(level: ThinkingLevel): Promise<void> {
try {
const previousLevel = this.thinkingLevel;
@@ -866,16 +893,70 @@ export class AgentHarness<
this.pendingSessionWrites.push({ type: "thinking_level_change", thinkingLevel: level });
}
this.thinkingLevel = level;
await this.emitOwn({ type: "thinking_level_select", level, previousLevel });
await this.emitOwn({ type: "thinking_level_update", level, previousLevel });
} catch (error) {
throw normalizeHarnessError(error, "session");
}
}
getTools(): TTool[] {
return [...this.tools.values()];
}
async setTools(tools: TTool[], activeToolNames?: string[]): Promise<void> {
try {
this.validateUniqueNames(
tools.map((tool) => tool.name),
"Duplicate tool name(s)",
);
const nextTools = new Map(tools.map((tool) => [tool.name, tool]));
const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames;
this.validateToolNames(nextActiveToolNames, nextTools);
const previousToolNames = [...this.tools.keys()];
const previousActiveToolNames = [...this.activeToolNames];
if (this.phase === "idle") {
await this.session.appendActiveToolsChange(nextActiveToolNames);
} else {
this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...nextActiveToolNames] });
}
this.tools = nextTools;
this.activeToolNames = [...nextActiveToolNames];
await this.emitOwn({
type: "tools_update",
toolNames: [...this.tools.keys()],
previousToolNames,
activeToolNames: [...this.activeToolNames],
previousActiveToolNames,
source: "set",
});
} catch (error) {
throw normalizeHarnessError(error, "invalid_argument");
}
}
getActiveTools(): TTool[] {
return this.activeToolNames.map((name) => this.tools.get(name)!);
}
async setActiveTools(toolNames: string[]): Promise<void> {
try {
this.validateToolNames(toolNames);
const previousToolNames = [...this.tools.keys()];
const previousActiveToolNames = [...this.activeToolNames];
if (this.phase === "idle") {
await this.session.appendActiveToolsChange(toolNames);
} else {
this.pendingSessionWrites.push({ type: "active_tools_change", activeToolNames: [...toolNames] });
}
this.activeToolNames = [...toolNames];
await this.emitOwn({
type: "tools_update",
toolNames: [...this.tools.keys()],
previousToolNames,
activeToolNames: [...this.activeToolNames],
previousActiveToolNames,
source: "set",
});
} catch (error) {
throw normalizeHarnessError(error, "invalid_argument");
}
@@ -921,18 +1002,6 @@ export class AgentHarness<
this.streamOptions = cloneStreamOptions(streamOptions);
}
async setTools(tools: TTool[], activeToolNames?: string[]): Promise<void> {
try {
const nextTools = new Map(tools.map((tool) => [tool.name, tool]));
const nextActiveToolNames = activeToolNames ? [...activeToolNames] : this.activeToolNames;
this.validateToolNames(nextActiveToolNames, nextTools);
this.tools = nextTools;
this.activeToolNames = [...nextActiveToolNames];
} catch (error) {
throw normalizeHarnessError(error, "invalid_argument");
}
}
async abort(): Promise<AbortResult> {
const clearedSteer = [...this.steerQueue];
const clearedFollowUp = [...this.followUpQueue];

View File

@@ -112,6 +112,7 @@ function getMessageFromEntry(entry: SessionTreeEntry): AgentMessage | undefined
return createCompactionSummaryMessage(entry.summary, entry.tokensBefore, entry.timestamp);
case "thinking_level_change":
case "model_change":
case "active_tools_change":
case "custom":
case "label":
case "session_info":

View File

@@ -281,6 +281,7 @@ function findValidCutPoints(entries: SessionTreeEntry[], startIndex: number, end
}
case "thinking_level_change":
case "model_change":
case "active_tools_change":
case "compaction":
case "branch_summary":
case "custom":

View File

@@ -2,6 +2,7 @@ import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
import type { AgentMessage } from "../../types.ts";
import { createBranchSummaryMessage, createCompactionSummaryMessage, createCustomMessage } from "../messages.ts";
import type {
ActiveToolsChangeEntry,
BranchSummaryEntry,
CompactionEntry,
CustomEntry,
@@ -21,6 +22,7 @@ import { SessionError } from "../types.ts";
export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionContext {
let thinkingLevel = "off";
let model: { provider: string; modelId: string } | null = null;
let activeToolNames: string[] | null = null;
let compaction: CompactionEntry | null = null;
for (const entry of pathEntries) {
@@ -30,6 +32,8 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon
model = { provider: entry.provider, modelId: entry.modelId };
} else if (entry.type === "message" && entry.message.role === "assistant") {
model = { provider: entry.message.provider, modelId: entry.message.model };
} else if (entry.type === "active_tools_change") {
activeToolNames = [...entry.activeToolNames];
} else if (entry.type === "compaction") {
compaction = entry;
}
@@ -72,7 +76,7 @@ export function buildSessionContext(pathEntries: SessionTreeEntry[]): SessionCon
}
}
return { messages, thinkingLevel, model };
return { messages, thinkingLevel, model, activeToolNames };
}
export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
@@ -156,6 +160,16 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
} satisfies ModelChangeEntry);
}
async appendActiveToolsChange(activeToolNames: string[]): Promise<string> {
return this.appendTypedEntry({
type: "active_tools_change",
id: await this.storage.createEntryId(),
parentId: await this.storage.getLeafId(),
timestamp: new Date().toISOString(),
activeToolNames: [...activeToolNames],
} satisfies ActiveToolsChangeEntry);
}
async appendCompaction<T = unknown>(
summary: string,
firstKeptEntryId: string,

View File

@@ -354,6 +354,11 @@ export interface ModelChangeEntry extends SessionTreeEntryBase {
modelId: string;
}
export interface ActiveToolsChangeEntry extends SessionTreeEntryBase {
type: "active_tools_change";
activeToolNames: string[];
}
export interface CompactionEntry<T = unknown> extends SessionTreeEntryBase {
type: "compaction";
summary: string;
@@ -405,6 +410,7 @@ export type SessionTreeEntry =
| MessageEntry
| ThinkingLevelChangeEntry
| ModelChangeEntry
| ActiveToolsChangeEntry
| CompactionEntry
| BranchSummaryEntry
| CustomEntry
@@ -417,6 +423,7 @@ export interface SessionContext {
messages: AgentMessage[];
thinkingLevel: string;
model: { provider: string; modelId: string } | null;
activeToolNames: string[] | null;
}
export interface SessionMetadata {
@@ -593,19 +600,28 @@ export interface SessionTreeEvent {
fromHook?: boolean;
}
export interface ModelSelectEvent {
type: "model_select";
export interface ModelUpdateEvent {
type: "model_update";
model: Model<any>;
previousModel: Model<any> | undefined;
source: "set" | "restore";
}
export interface ThinkingLevelSelectEvent {
type: "thinking_level_select";
export interface ThinkingLevelUpdateEvent {
type: "thinking_level_update";
level: ThinkingLevel;
previousLevel: ThinkingLevel;
}
export interface ToolsUpdateEvent {
type: "tools_update";
toolNames: string[];
previousToolNames: string[];
activeToolNames: string[];
previousActiveToolNames: string[];
source: "set" | "restore";
}
export interface ResourcesUpdateEvent<
TSkill extends Skill = Skill,
TPromptTemplate extends PromptTemplate = PromptTemplate,
@@ -634,9 +650,10 @@ export type AgentHarnessOwnEvent<
| SessionCompactEvent
| SessionBeforeTreeEvent
| SessionTreeEvent
| ModelSelectEvent
| ThinkingLevelSelectEvent
| ResourcesUpdateEvent<TSkill, TPromptTemplate>;
| ModelUpdateEvent
| ThinkingLevelUpdateEvent
| ResourcesUpdateEvent<TSkill, TPromptTemplate>
| ToolsUpdateEvent;
export type AgentHarnessEvent<TSkill extends Skill = Skill, TPromptTemplate extends PromptTemplate = PromptTemplate> =
| AgentEvent
@@ -696,9 +713,10 @@ export type AgentHarnessEventResultMap = {
session_compact: undefined;
session_before_tree: SessionBeforeTreeResult | undefined;
session_tree: undefined;
model_select: undefined;
thinking_level_select: undefined;
model_update: undefined;
thinking_level_update: undefined;
resources_update: undefined;
tools_update: undefined;
queue_update: undefined;
save_point: undefined;
abort: undefined;