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:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user