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:
Yaowei Zheng
2026-07-26 16:31:51 +08:00
committed by GitHub
parent 816a823b75
commit 1e23452f48
58 changed files with 1064 additions and 336 deletions
+14
View File
@@ -473,6 +473,14 @@ export interface SessionInfo {
hasTrace: boolean;
/** Whether archived (hidden from the default list, grouped under "Archived"). */
archived: boolean;
/**
* Absolute path of the session's latest Trace file (the current context shard); absent
* when no Trace exists yet. Populated on the **single-session GET only** — list rows omit
* it (locating it costs a directory walk per Session). The web's `/model` switch puts it
* into the new session's `<model_switch_from>` block so the model can read the source
* history itself when it needs it.
*/
tracePath?: string;
}
/**
@@ -562,6 +570,12 @@ export type TaskInputPart =
export interface TaskCreateRequest {
input: TaskInputPart[];
/**
* Thinking level for this Task's LLM requests (a per-turn parameter; one of
* `none | low | medium | high | xhigh`, anything else is a 400). Omitted = falls back to
* the session's default (the Agent config's `model.thinking_level`).
*/
thinkingLevel?: ThinkingLevelName;
}
export interface TaskCreateResponse {
+18 -4
View File
@@ -11,7 +11,7 @@ import path from "node:path";
import { Hono } from "hono";
import type { Context } from "hono";
import { imageUrlMessage, scratchpadDir, userText } from "@prismshadow/penguin-core";
import type { OmniMessage } from "@prismshadow/penguin-core";
import type { OmniMessage, ThinkingLevelName } from "@prismshadow/penguin-core";
import type {
ApprovalMode,
FilesStatResponse,
@@ -58,6 +58,9 @@ const APPROVAL_MODES: readonly ApprovalMode[] = [
"always-ask",
];
/** The five valid per-turn thinking level names (TaskCreateRequest.thinkingLevel). */
const THINKING_LEVELS: readonly ThinkingLevelName[] = ["none", "low", "medium", "high", "xhigh"];
/** Accepted `category` query values of the list endpoint (SessionCategory, spelled out for validation). */
const SESSION_CATEGORIES: readonly SessionCategory[] = [
"active",
@@ -201,8 +204,13 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
app.get("/:sessionId", async (c) => {
const row = resolveSession(c);
const hasTrace = await deps.sessionService.hasTrace(row);
const info = await deps.sessionService.toInfo(row, hasTrace);
// Single-session GET only: the latest Trace file's absolute path (a directory walk per
// call — too costly for list rows). The web's /model switch hands it to the new session's
// <model_switch_from> block so the model can read the source history itself.
const tracePath = hasTrace ? await deps.sessionService.latestTracePath(row) : undefined;
return c.json({
session: await deps.sessionService.toInfo(row, hasTrace),
session: { ...info, ...(tracePath !== undefined ? { tracePath } : {}) },
} satisfies SessionResponse);
});
@@ -357,9 +365,15 @@ export function sessionsRoutes(deps: AppDeps): Hono<AppEnv> {
app.post("/:sessionId/tasks", async (c) => {
const row = resolveSession(c);
const input = parseTaskInput(await readJson(c));
const body = await readJson(c);
const input = parseTaskInput(body);
// Per-turn thinking level (optional): validated against the five names; omitted follows
// the session's default.
const thinkingLevel = optionalEnum(body, "thinkingLevel", THINKING_LEVELS);
// 202: the Task executes on the server, decoupled from the SSE connection; sessionId is the current actual id (the new id after self-heal).
const { sessionId } = await deps.manager.startTask(row.sessionId, input);
const { sessionId } = await deps.manager.startTask(row.sessionId, input, {
...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
});
return c.json({ sessionId } satisfies TaskCreateResponse, 202);
});
+14 -3
View File
@@ -40,6 +40,7 @@ import type {
SessionMetaPayload,
SessionTitleResult,
TextPayload,
ThinkingLevelName,
} from "@prismshadow/penguin-core";
import type { ServerEvent, SessionStatus } from "../api/types.js";
import { HttpError, isMissingCredential, modelCredentialMissing } from "../http/errors.js";
@@ -70,7 +71,7 @@ export interface RuntimeSession {
readonly sessionId: string;
run(
newMessages: OmniMessage[],
opts: { approve: ApproveFn; signal: AbortSignal },
opts: { approve: ApproveFn; signal: AbortSignal; thinkingLevel?: ThinkingLevelName },
): AsyncGenerator<OmniMessage>;
compact(opts: { signal: AbortSignal }): AsyncGenerator<OmniMessage>;
/** Whether compaction is possible and why; when not ok, compact() yields no messages (see core ContextEngine.compactability). */
@@ -379,8 +380,14 @@ export class SessionManager {
* Start a Task: get-or-load → 409
* mutual-exclusion check → publish the input messages first → drive run in the
* background. Returns the current actual session_id (the new id after self-heal).
* `opts.thinkingLevel` (optional, validated by the route) rides into this run's
* `session.run` options — a per-turn parameter, applied to this Task only.
*/
async startTask(sessionId: string, input: OmniMessage[]): Promise<{ sessionId: string }> {
async startTask(
sessionId: string,
input: OmniMessage[],
opts?: { thinkingLevel?: ThinkingLevelName },
): Promise<{ sessionId: string }> {
return this.withLock(sessionId, async () => {
this.assertOpen();
this.assertAgentNotDeleting(sessionId);
@@ -409,7 +416,11 @@ export class SessionManager {
...(pending.origin !== undefined ? { origin: pending.origin } : {}),
}),
});
const gen = entry.session.run(input, { approve, signal: ac.signal });
const gen = entry.session.run(input, {
approve,
signal: ac.signal,
...(opts?.thinkingLevel !== undefined ? { thinkingLevel: opts.thinkingLevel } : {}),
});
// Title material is collected by the core Session itself during run; here we only
// keep this call's input user text, used both as the "material present → attempt
// generation" criterion and as the fallback title source if the LLM call fails.
@@ -16,6 +16,7 @@ import path from "node:path";
import { open, readdir } from "node:fs/promises";
import {
createAgent,
findLatestTraceFile,
isSessionMeta,
parseTraceLines,
readTraceTolerant,
@@ -389,6 +390,21 @@ export class SessionService {
return this.toInfo(row, false);
}
/**
* Absolute path of a Session's **latest** Trace file (the current context shard);
* undefined when no Trace exists. Costs a directory walk, so only the single-session
* GET includes it in the DTO (see SessionInfo.tracePath) — the web's `/model` switch
* hands it to the new session's `<model_switch_from>` block so the model can read the
* source history itself when it needs it.
*/
async latestTracePath(row: SessionRow): Promise<string | undefined> {
const located = await findLatestTraceFile(
tracesDir(this.deps.root, row.projectId, row.agentId),
row.sessionId,
);
return located?.path;
}
/**
* One walk over the Trace directory: session_id → its **earliest** shard (the shard
* whose head carries the original session_meta). Discovery (which Sessions have