Handoff-style /model switch command and per-turn thinking level (#64)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+16
-11
@@ -70,7 +70,7 @@ import type { ModelEntry } from "./state/index.js";
|
||||
*/
|
||||
const MAX_SUBAGENT_DEPTH = 1;
|
||||
|
||||
/** The five valid thinking level names (session_meta additionally records the literal "default" for "no level"). */
|
||||
/** The five valid thinking level names (legacy session_meta additionally recorded the literal "default" for "no level"). */
|
||||
const THINKING_LEVEL_NAMES: readonly ThinkingLevelName[] = [
|
||||
"none",
|
||||
"low",
|
||||
@@ -80,9 +80,9 @@ const THINKING_LEVEL_NAMES: readonly ThinkingLevelName[] = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Narrows a recorded session_meta `thinking_level` back to a ThinkingLevelName; the literal
|
||||
* "default" (no level recorded), a missing field (legacy Trace), and anything unknown are
|
||||
* not levels and yield undefined.
|
||||
* Narrows a legacy session_meta `thinking_level` back to a ThinkingLevelName; the literal
|
||||
* "default" (no level recorded), a missing field (current Traces no longer record one), and
|
||||
* anything unknown are not levels and yield undefined.
|
||||
*/
|
||||
function asThinkingLevelName(value: unknown): ThinkingLevelName | undefined {
|
||||
return typeof value === "string" && (THINKING_LEVEL_NAMES as readonly string[]).includes(value)
|
||||
@@ -291,6 +291,8 @@ export class Agent {
|
||||
});
|
||||
|
||||
return new Session({
|
||||
// session_meta holds per-session invariants only: the thinking level is a per-turn
|
||||
// run parameter (RunOptions.thinkingLevel) and is deliberately not recorded here.
|
||||
meta: {
|
||||
session_id: sessionId,
|
||||
provider: modelEntry.provider,
|
||||
@@ -298,7 +300,6 @@ export class Agent {
|
||||
model_context_window: modelEntry.context_window ?? "unknown",
|
||||
system_prompt: systemPrompt,
|
||||
tools: rt.tools,
|
||||
thinking_level: thinkingLevel ?? "default",
|
||||
agent_state: this.state.stateDir,
|
||||
workspace: workspaceDir,
|
||||
...(opts.source !== undefined ? { source: opts.source } : {}),
|
||||
@@ -393,12 +394,15 @@ export class Agent {
|
||||
// original history); the vault uses current values (it's injected into the
|
||||
// subprocess environment, not the history, so a resumed Session should get the
|
||||
// latest keys too).
|
||||
// The recorded thinking level is restored with the rest of session_meta (like model,
|
||||
// Workspace, and system prompt): a resumed subagent session keeps its inherited level
|
||||
// instead of re-reading this Agent's config. The literal "default" (no level recorded)
|
||||
// — and a legacy Trace without the field — falls back to the Agent's current config.
|
||||
// Back-compat: session_meta no longer records a thinking level (it became a per-turn
|
||||
// run parameter), but OLD Traces still carry `thinking_level` in their meta JSON — when
|
||||
// present, keep honoring it as this Session's default level (a resumed legacy subagent
|
||||
// session keeps its inherited level instead of re-reading this Agent's config). The
|
||||
// field is read loosely (it's gone from SessionMetaPayload); the legacy literal
|
||||
// "default" — and any current Trace without the field — falls back to the Agent config.
|
||||
const thinkingLevel =
|
||||
asThinkingLevelName(meta.thinking_level) ?? this.state.systemConfig.model?.thinking_level;
|
||||
asThinkingLevelName((meta as unknown as Record<string, unknown>).thinking_level) ??
|
||||
this.state.systemConfig.model?.thinking_level;
|
||||
|
||||
const rt = await this.buildRuntime({
|
||||
workspaceDir,
|
||||
@@ -438,6 +442,8 @@ export class Agent {
|
||||
});
|
||||
|
||||
return new Session({
|
||||
// Invariants only (the legacy thinking_level, when honored above, feeds the LLM default
|
||||
// but is not re-recorded — new meta writes never contain it).
|
||||
meta: {
|
||||
session_id: sessionId,
|
||||
provider: modelEntry.provider,
|
||||
@@ -445,7 +451,6 @@ export class Agent {
|
||||
model_context_window: modelEntry.context_window ?? "unknown",
|
||||
system_prompt: meta.system_prompt,
|
||||
tools: rt.tools,
|
||||
thinking_level: thinkingLevel ?? "default",
|
||||
agent_state: this.state.stateDir,
|
||||
workspace: workspaceDir,
|
||||
// The origin carries over from the original session_meta (a resumed scheduled/subagent
|
||||
|
||||
@@ -58,7 +58,13 @@ import type {
|
||||
ToolCallOutputPayload,
|
||||
ToolCallPayload,
|
||||
} from "../omnimessage/index.js";
|
||||
import type { ApproveFn, EnvironmentInterface, LLMInterface, LLMOutcome } from "../interfaces.js";
|
||||
import type {
|
||||
ApproveFn,
|
||||
EnvironmentInterface,
|
||||
LLMInterface,
|
||||
LLMOutcome,
|
||||
ThinkingLevelName,
|
||||
} from "../interfaces.js";
|
||||
|
||||
/** Trace sink: `write` a complete/event/meta message; `rotate` starts a new file (compaction splits files). */
|
||||
export interface TraceSink {
|
||||
@@ -96,6 +102,12 @@ export interface RunOptions {
|
||||
signal?: AbortSignal;
|
||||
/** Per-tool approval callback; defaults to denying everything (conservative, to avoid accidental approval when unattended). */
|
||||
approve?: ApproveFn;
|
||||
/**
|
||||
* Thinking level for this run's LLM requests (a per-turn parameter): forwarded to every
|
||||
* `streamGenerate` of this run — reconnect retries included; compaction requests keep the
|
||||
* construction-time default (no override). Omitted = the LLM object's default.
|
||||
*/
|
||||
thinkingLevel?: ThinkingLevelName;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -260,6 +272,9 @@ export class ContextEngine {
|
||||
const signal = opts?.signal;
|
||||
// Default approval policy: deny (conservative). CLI/Web will inject a real callback (interactive or permission-mode based).
|
||||
const approve: ApproveFn = opts?.approve ?? (async () => "deny");
|
||||
// Per-turn thinking level: applies to each of this run's LLM requests (reconnects included);
|
||||
// compaction requests are out of scope and keep the LLM default.
|
||||
const thinkingLevel = opts?.thinkingLevel;
|
||||
|
||||
// Merge the Task-boundary compaction summary (the new context's first input, merged with
|
||||
// this Prompt), the carry-over left over from the last interruption, and this call's new
|
||||
@@ -329,7 +344,7 @@ export class ContextEngine {
|
||||
// Both LLM and Environment handle errors internally and guarantee a complete, closed
|
||||
// output with no thrown exceptions; the engine doesn't handle exceptions —
|
||||
// it decides retry/resend purely from `outcome`.
|
||||
turn = yield* this.runTurn(attemptInput, approve, signal);
|
||||
turn = yield* this.runTurn(attemptInput, approve, signal, thinkingLevel);
|
||||
|
||||
// User interruption (the LLM stream was aborted, outcome=aborted, or `signal` fired
|
||||
// during tool execution): stop and hand control back to the user.
|
||||
@@ -517,6 +532,7 @@ export class ContextEngine {
|
||||
input: OmniMessage[],
|
||||
approve: ApproveFn,
|
||||
signal?: AbortSignal,
|
||||
thinkingLevel?: ThinkingLevelName,
|
||||
): AsyncGenerator<OmniMessage, TurnResult> {
|
||||
const queue = new MergeQueue();
|
||||
// Tool outputs are collected in **completion order** (for streaming yield to the frontend);
|
||||
@@ -547,6 +563,7 @@ export class ContextEngine {
|
||||
const gen = this.llm.streamGenerate({
|
||||
newMessages: input,
|
||||
...(signal ? { signal } : {}),
|
||||
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
|
||||
});
|
||||
for (;;) {
|
||||
const res = await gen.next();
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface GenerativeModelConfig {
|
||||
contextWindow?: number;
|
||||
/** Output token cap per Request; non-positive (-1) means no explicit cap (omitted from the request). */
|
||||
maxTokens?: number;
|
||||
/** Construction-time default thinking level; a per-request `GenerativeModelParameters.thinkingLevel` overrides it for that request. */
|
||||
thinkingLevel?: ThinkingLevelName;
|
||||
/** LLM Request timeout (ms): from system_config.model.timeoutMs; <=0 disables it. Defaults to 120000. */
|
||||
requestTimeoutMs?: number;
|
||||
@@ -118,6 +119,12 @@ export interface GenerativeModelParameters {
|
||||
/** OmniMessage array for the input newly added this turn; implementations must merge it into a single UniMessage (multiple roles not accepted). */
|
||||
newMessages: OmniMessage[];
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Per-request thinking level override: applied to **this request only**; omitted falls back
|
||||
* to the construction-time default (`GenerativeModelConfig.thinkingLevel`). The thinking
|
||||
* level is a per-turn parameter, not a Session invariant.
|
||||
*/
|
||||
thinkingLevel?: ThinkingLevelName;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,11 +29,11 @@ const TITLE_MAX_CHARS = 30;
|
||||
/**
|
||||
* Special message markers that must never leak into a title. These are machine-inserted
|
||||
* XML-ish blocks (a skill invocation wraps the body in `<use_skills>…</use_skills>`, a
|
||||
* subagent handoff / scheduled task prepend their own blocks) — meaningful to the runtime,
|
||||
* noise in a title. The list is a fixed allowlist so ordinary angle-bracket text (e.g. a
|
||||
* user pasting `<div>`) is left untouched.
|
||||
* subagent handoff / scheduled task / model switch prepend their own blocks) — meaningful
|
||||
* to the runtime, noise in a title. The list is a fixed allowlist so ordinary angle-bracket
|
||||
* text (e.g. a user pasting `<div>`) is left untouched.
|
||||
*/
|
||||
const MARKER_TAGS = ["use_skills", "handoff_from", "scheduled_task"];
|
||||
const MARKER_TAGS = ["use_skills", "handoff_from", "scheduled_task", "model_switch_from"];
|
||||
|
||||
/**
|
||||
* Strips machine-inserted marker blocks (see MARKER_TAGS) from conversation text so titles are
|
||||
|
||||
@@ -863,6 +863,13 @@ export function isRetryableError(error: unknown): boolean {
|
||||
export class GenerativeModel implements LLMInterface {
|
||||
private readonly client: AutoLLMClient;
|
||||
private readonly uniConfig: UniConfig;
|
||||
/**
|
||||
* Construction-time default thinking level. Kept **out of the frozen uniConfig**: the
|
||||
* effective level is resolved per request (`params.thinkingLevel ?? default`), so a turn can
|
||||
* override it without rebuilding the model object — the thinking level is a per-turn
|
||||
* parameter, not a Session invariant.
|
||||
*/
|
||||
private readonly defaultThinkingLevel: ThinkingLevelName | undefined;
|
||||
/** Streaming idle timeout (milliseconds); <= 0 disables it. A timeout is treated as needing reconnection. */
|
||||
private readonly requestTimeoutMs: number;
|
||||
/**
|
||||
@@ -889,10 +896,23 @@ export class GenerativeModel implements LLMInterface {
|
||||
});
|
||||
|
||||
this.uniConfig = buildUniConfig(config);
|
||||
this.defaultThinkingLevel = config.thinkingLevel;
|
||||
this.requestTimeoutMs = config.requestTimeoutMs ?? 120000;
|
||||
this.toolCallIds = config.toolCallIds ?? new ToolCallIdAllocator();
|
||||
}
|
||||
|
||||
/**
|
||||
* The UniConfig for one request: the shared frozen config plus this request's effective
|
||||
* thinking level (per-request override, else the construction-time default; neither → the
|
||||
* key stays off the wire, preserving the provider default).
|
||||
*/
|
||||
private requestConfig(override: ThinkingLevelName | undefined): UniConfig {
|
||||
const thinking = mapThinkingLevel(override ?? this.defaultThinkingLevel);
|
||||
return thinking === undefined
|
||||
? this.uniConfig
|
||||
: { ...this.uniConfig, thinking_level: thinking };
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming generation (a single attempt, no internal retry). Merges
|
||||
* `params.newMessages` into one UniMessage to issue a stateful request, translating streamed
|
||||
@@ -967,7 +987,9 @@ export class GenerativeModel implements LLMInterface {
|
||||
// parse error) / aborted (user) / failed (other). null means it ended normally.
|
||||
let outcome: LLMOutcome | null = null;
|
||||
try {
|
||||
const it = this.openStream(uniMessage, ac.signal)[Symbol.asyncIterator]();
|
||||
const it = this.openStream(uniMessage, ac.signal, this.requestConfig(params.thinkingLevel))[
|
||||
Symbol.asyncIterator
|
||||
]();
|
||||
for (;;) {
|
||||
// The interruption check must happen **before pulling from upstream**: the user may
|
||||
// interrupt while this generator is suspended at the `yield` below (the typical case —
|
||||
@@ -1087,12 +1109,17 @@ export class GenerativeModel implements LLMInterface {
|
||||
* Opens the underlying AgentHub stream (a testing seam): defaults to
|
||||
* `streamingResponseStateful`; unit tests can subclass and override this method, feeding in a
|
||||
* controlled UniEvent stream to verify the outcome classification for timeout/network
|
||||
* drop/interruption/error (without a real API).
|
||||
* drop/interruption/error (without a real API). `config` is this request's resolved
|
||||
* UniConfig (the shared frozen config plus the per-request thinking level).
|
||||
*/
|
||||
protected openStream(uniMessage: UniMessage, signal: AbortSignal): AsyncIterable<UniEvent> {
|
||||
protected openStream(
|
||||
uniMessage: UniMessage,
|
||||
signal: AbortSignal,
|
||||
config: UniConfig = this.uniConfig,
|
||||
): AsyncIterable<UniEvent> {
|
||||
return this.client.streamingResponseStateful({
|
||||
message: uniMessage,
|
||||
config: this.uniConfig,
|
||||
config,
|
||||
signal,
|
||||
});
|
||||
}
|
||||
@@ -1133,6 +1160,10 @@ export function toolDefinitionsToSchemas(tools: ToolDefinition[]): ToolSchema[]
|
||||
* protocol equivalent. `tool_choice` is likewise never set — AgentHub only puts it on the wire
|
||||
* when UniConfig defines it, and leaving it off preserves the protocol default ("auto" when
|
||||
* tools are present).
|
||||
*
|
||||
* `thinkingLevel` is deliberately **not** baked in here: the effective level is resolved per
|
||||
* request (`GenerativeModelParameters.thinkingLevel ?? the construction default`, see
|
||||
* `GenerativeModel.requestConfig`), so a turn can override it on a live session.
|
||||
*/
|
||||
export function buildUniConfig(config: GenerativeModelConfig): UniConfig {
|
||||
const uniConfig: UniConfig = {};
|
||||
@@ -1148,9 +1179,5 @@ export function buildUniConfig(config: GenerativeModelConfig): UniConfig {
|
||||
if (config.maxTokens !== undefined && config.maxTokens > 0) {
|
||||
uniConfig.max_tokens = config.maxTokens;
|
||||
}
|
||||
const thinking = mapThinkingLevel(config.thinkingLevel);
|
||||
if (thinking !== undefined) {
|
||||
uniConfig.thinking_level = thinking;
|
||||
}
|
||||
return uniConfig;
|
||||
}
|
||||
|
||||
@@ -75,6 +75,12 @@ export interface ToolDefinition {
|
||||
parameters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session metadata. Holds **per-session invariants only** — values fixed for the Session's
|
||||
* lifetime (model reference, assembled system prompt, tool schemas, paths, origin). Per-turn
|
||||
* parameters (e.g. the thinking level, passed with each run) never belong here; legacy Traces
|
||||
* may still carry a `thinking_level` field, which resume reads loosely for back-compat.
|
||||
*/
|
||||
export interface SessionMetaPayload {
|
||||
session_id: string;
|
||||
/** The session model's provider group (paired with `model_id` to form a model reference). */
|
||||
@@ -86,8 +92,6 @@ export interface SessionMetaPayload {
|
||||
system_prompt: string;
|
||||
/** The list of tool definitions this Session exposes to the model (full schema, matching what's sent to the LLM). */
|
||||
tools: ToolDefinition[];
|
||||
/** The Session's effective thinking level (an explicit createSession option — e.g. subagent inheritance — else system_config.model.thinking_level; "default" when unconfigured). */
|
||||
thinking_level: string;
|
||||
/** Absolute path to the Agent State. */
|
||||
agent_state: string;
|
||||
/** Absolute path to the Workspace. */
|
||||
|
||||
@@ -33,7 +33,7 @@ import type {
|
||||
} from "./engine/context-engine.js";
|
||||
|
||||
export interface SessionConfig {
|
||||
/** Session metadata (session_id / provider / model_id / model_context_window / system_prompt / tools / thinking_level / agent_state / workspace). */
|
||||
/** Session metadata — per-session invariants only (session_id / provider / model_id / model_context_window / system_prompt / tools / agent_state / workspace / source). */
|
||||
meta: SessionMetaPayload;
|
||||
llm: LLMInterface;
|
||||
environment: EnvironmentInterface;
|
||||
|
||||
Reference in New Issue
Block a user