refactor(agent): make harness resources explicit

This commit is contained in:
Mario Zechner
2026-05-10 00:18:50 +02:00
parent fe6b85b32c
commit 79db9d62ef
4 changed files with 64 additions and 8 deletions

View File

@@ -16,13 +16,17 @@ Harness config is the latest runtime configuration set by the application or ext
- thinking level - thinking level
- tools - tools
- active tool names - active tool names
- resources or resource provider - resources
- system prompt or system prompt provider - system prompt or system prompt provider
Getters return harness config. They do not return the snapshot used by an in-flight provider request. Getters return harness config. They do not return the snapshot used by an in-flight provider request.
Setters update harness config immediately, including while a turn is in flight. Changes affect the next turn snapshot, not the currently running provider request. Setters update harness config immediately, including while a turn is in flight. Changes affect the next turn snapshot, not the currently running provider request.
`setResources()` accepts concrete resources and emits `resources_update` with shallow-copied resources. Applications own loading/reloading resources from disk or other sources and should call `setResources()` with new values.
`getResources()` returns shallow-copied current resources. It is a live config read, not the last turn snapshot.
### Turn snapshot ### Turn snapshot
A turn snapshot is the concrete state used for one LLM turn. It is created by `createTurnState()` and contains: A turn snapshot is the concrete state used for one LLM turn. It is created by `createTurnState()` and contains:
@@ -35,7 +39,9 @@ A turn snapshot is the concrete state used for one LLM turn. It is created by `c
- all tools - all tools
- active tools - active tools
Static option values are used directly. Provider callbacks are invoked once per `createTurnState()` call. All logic for that turn uses the same snapshot. Static option values are used directly. System-prompt provider callbacks are invoked once per `createTurnState()` call. All logic for that turn uses the same snapshot.
Resource arrays are shallow-copied when a snapshot is created. Individual skill and prompt-template objects are not deep-copied.
### Session ### Session
@@ -116,6 +122,8 @@ The low-level loop converts harness `ThinkingLevel` to provider `reasoning` at t
No state refresh is needed on `agent_end` except flushing leftover pending session writes and clearing the operation phase. The exact `settled` event timing is still under review. No state refresh is needed on `agent_end` except flushing leftover pending session writes and clearing the operation phase. The exact `settled` event timing is still under review.
If the system-prompt callback throws while starting `prompt`, `skill`, or `promptFromTemplate`, the operation throws and the harness returns to idle. If it throws from the save-point snapshot created by `prepareNextTurn`, the low-level agent run records an assistant error message.
## Hooks and events ## Hooks and events
Current hooks receive only the event payload. There is no extension context object yet. Current hooks receive only the event payload. There is no extension context object yet.

View File

@@ -38,7 +38,7 @@ export class AgentHarness {
private steerQueue: UserMessage[] = []; private steerQueue: UserMessage[] = [];
private followUpQueue: UserMessage[] = []; private followUpQueue: UserMessage[] = [];
private pendingSessionWrites: PendingSessionWrite[] = []; private pendingSessionWrites: PendingSessionWrite[] = [];
private resources?: AgentHarnessOptions["resources"]; private resources: AgentHarnessResources;
private systemPrompt: AgentHarnessOptions["systemPrompt"]; private systemPrompt: AgentHarnessOptions["systemPrompt"];
private getApiKeyAndHeaders?: AgentHarnessOptions["getApiKeyAndHeaders"]; private getApiKeyAndHeaders?: AgentHarnessOptions["getApiKeyAndHeaders"];
private tools = new Map<string, AgentTool>(); private tools = new Map<string, AgentTool>();
@@ -57,7 +57,7 @@ export class AgentHarness {
}); });
this.env = options.env; this.env = options.env;
this.session = options.session; this.session = options.session;
this.resources = options.resources; this.resources = options.resources ?? {};
this.systemPrompt = options.systemPrompt; this.systemPrompt = options.systemPrompt;
this.getApiKeyAndHeaders = options.getApiKeyAndHeaders; this.getApiKeyAndHeaders = options.getApiKeyAndHeaders;
for (const tool of this.agent.state.tools) { for (const tool of this.agent.state.tools) {
@@ -165,7 +165,7 @@ export class AgentHarness {
private async createTurnState(): Promise<AgentHarnessTurnState> { private async createTurnState(): Promise<AgentHarnessTurnState> {
const context = await this.session.buildContext(); const context = await this.session.buildContext();
const resources = typeof this.resources === "function" ? await this.resources() : (this.resources ?? {}); const resources = this.getResources();
const tools = [...this.tools.values()]; const tools = [...this.tools.values()];
const activeTools = this.activeToolNames const activeTools = this.activeToolNames
.map((name) => this.tools.get(name)) .map((name) => this.tools.get(name))
@@ -578,8 +578,19 @@ export class AgentHarness {
this.agent.followUpMode = mode; this.agent.followUpMode = mode;
} }
getResources(): AgentHarnessResources {
return {
skills: this.resources.skills?.slice(),
promptTemplates: this.resources.promptTemplates?.slice(),
};
}
async setResources(resources: AgentHarnessResources): Promise<void> { async setResources(resources: AgentHarnessResources): Promise<void> {
this.resources = resources; this.resources = {
skills: resources.skills?.slice(),
promptTemplates: resources.promptTemplates?.slice(),
};
await this.emitOwn({ type: "resources_update", resources: this.getResources() });
} }
async setTools(tools: AgentTool[], activeToolNames?: string[]): Promise<void> { async setTools(tools: AgentTool[], activeToolNames?: string[]): Promise<void> {

View File

@@ -410,6 +410,11 @@ export interface ThinkingLevelSelectEvent {
previousLevel: ThinkingLevel; previousLevel: ThinkingLevel;
} }
export interface ResourcesUpdateEvent {
type: "resources_update";
resources: AgentHarnessResources;
}
export type AgentHarnessOwnEvent = export type AgentHarnessOwnEvent =
| QueueUpdateEvent | QueueUpdateEvent
| SavePointEvent | SavePointEvent
@@ -426,7 +431,8 @@ export type AgentHarnessOwnEvent =
| SessionBeforeTreeEvent | SessionBeforeTreeEvent
| SessionTreeEvent | SessionTreeEvent
| ModelSelectEvent | ModelSelectEvent
| ThinkingLevelSelectEvent; | ThinkingLevelSelectEvent
| ResourcesUpdateEvent;
export type AgentHarnessEvent = AgentEvent | AgentHarnessOwnEvent; export type AgentHarnessEvent = AgentEvent | AgentHarnessOwnEvent;
@@ -481,6 +487,7 @@ export type AgentHarnessEventResultMap = {
session_tree: undefined; session_tree: undefined;
model_select: undefined; model_select: undefined;
thinking_level_select: undefined; thinking_level_select: undefined;
resources_update: undefined;
queue_update: undefined; queue_update: undefined;
save_point: undefined; save_point: undefined;
abort: undefined; abort: undefined;
@@ -565,7 +572,11 @@ export interface AgentHarnessOptions {
env: ExecutionEnv; env: ExecutionEnv;
session: Session; session: Session;
tools?: AgentTool[]; tools?: AgentTool[];
resources?: AgentHarnessResources | (() => AgentHarnessResources | Promise<AgentHarnessResources>); /**
* Concrete resources available to explicit invocation methods and system-prompt callbacks.
* Applications own loading/reloading resources and should call `setResources()` with new values.
*/
resources?: AgentHarnessResources;
systemPrompt?: systemPrompt?:
| string | string
| ((context: { | ((context: {

View File

@@ -35,4 +35,30 @@ describe("harness factories", () => {
expect(harness.agent.steeringMode).toBe("one-at-a-time"); expect(harness.agent.steeringMode).toBe("one-at-a-time");
expect(harness.agent.followUpMode).toBe("one-at-a-time"); expect(harness.agent.followUpMode).toBe("one-at-a-time");
}); });
it("updates and reads concrete resources", async () => {
const session = createSession(new InMemorySessionStorage());
const env = new NodeExecutionEnv({ cwd: process.cwd() });
const model = getModel("anthropic", "claude-sonnet-4-5");
const harness = createAgentHarness({ env, session, model });
const skill = {
name: "inspect",
description: "Inspect things",
content: "Use inspection tools.",
filePath: "/skills/inspect/SKILL.md",
};
const resources = { skills: [skill], promptTemplates: [{ name: "review", content: "Review $1" }] };
const updates: unknown[] = [];
harness.subscribe((event) => {
if (event.type === "resources_update") updates.push(event.resources);
});
await harness.setResources(resources);
const resolved = harness.getResources();
expect(updates).toEqual([resources]);
expect(resolved).toEqual(resources);
expect(resolved.skills).not.toBe(resources.skills);
expect(resolved.promptTemplates).not.toBe(resources.promptTemplates);
});
}); });