Skip to content

Commit 177c14d

Browse files
committed
Merge branch 'feature/thinking-level-ui'
2 parents 10d2bfb + d012327 commit 177c14d

9 files changed

Lines changed: 359 additions & 26 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ projects/
8686

8787
- **One flat agent.** For independent/parallel subtasks the agent calls the `subagent` tool from the **pi-subagents** package, which spawns child `pi` CLI processes in the sandbox (the binary resolves from `server/node_modules/.bin`). Specialist scientific agents are seeded into each project's `sandbox/.pi/agents/*.md` from `server/src/agent/subagents.ts` (write-if-missing; user edits win). Budget gating + cost ledgering for child runs lives in `server/src/agent/subagent-bridge.ts`.
8888
- **Interview tool (clarifying questions).** The `interview` custom tool (`server/src/agent/interview.ts`) blocks the run on a pending-answer promise; the questions ride the normal `tool_start` SSE frame and the chat UI renders them as an inline form (`web/src/components/interview-form.tsx`), POSTing answers to `/sessions/:id/interview/:toolCallId`. Tool `promptGuidelines` + the seeded `AGENTS.md` push the agent to interview liberally before assuming. Question schema mirrors pi-interview (single/multi/text/image/info, recommended/conviction/weight, code `content`, image/table/mermaid/chart/html `media`); user-uploaded images return to the model as image blocks. Deliberately NOT exposed to sub-agent child processes — they are headless and must not block on user input.
89+
- **Thinking level.** Each chat tab has a per-run thinking-level selector (`web/src/components/thinking-selector.tsx`, default `high`); the run body's `thinkingLevel` is validated by `server/src/agent/thinking.ts` and applied via Pi's `session.setThinkingLevel()` after `setModel`. Ollama and Fusion runs send no level (chip disabled). Caveat: like `setModel`, Pi's `setThinkingLevel` also persists the value as the **global default** in `~/.pi/agent/settings.json` — so the last level picked in any tab becomes the starting level for subagent child `pi` processes and for the user's own `pi` CLI. Pin a `thinking` level in a specialist's frontmatter if a subagent must not inherit it.
8990
- **Modal remote compute (`modal_run`).** Gated on `MODAL_TOKEN_ID`/`MODAL_TOKEN_SECRET` (managed live via `/credentials`; `modalConfigured()` in `server/src/config.ts`). `server/src/agent/modal-tool.ts` is an in-process custom tool, registered in `session-registry.ts` only when configured. It uses the `modal` JS SDK to create an isolated Sandbox on a chosen instance (`server/src/agent/modal-instances.ts` catalogue — `cpu`→`h100`, with GPU string + hourly rate), stages `files_in`, runs the command, copies `files_out` back into the local sandbox (which stays the canonical filesystem — no split-brain), and ledgers wall-time × rate as a `compute` row. The per-chat instance is picked in the UI (`web/src/components/compute-selector.tsx`, gated on `modalConfigured`) and threaded through the run body → `setSessionComputeTarget` → the tool's session default. Like `interview`, it is an in-process custom tool, so it is NOT seen by sub-agent child `pi` processes; promoting it to a Pi package (mirroring web-access) would extend it to them. No secrets are injected into the sandbox by default.
9091
- **OpenRouter cost** is read from Pi's `usage.cost` (computed from `model.cost`). For synthesized OpenRouter models the pricing comes from `web/src/data/models.json`; keep that catalogue current for accurate cost.
9192
- **Node ≥ 22.19** is what Pi targets; lower 22.x usually works but emits an `EBADENGINE` warning. Node < 22 (e.g. v20) fails to build/install the packages, so `start.sh` refuses to run on it.

server/src/agent/thinking.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Thinking-level validation for the run endpoint. Pi's session clamps the
3+
* level per model capability; this only guards the untrusted wire value.
4+
*/
5+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
6+
7+
export const THINKING_LEVELS: readonly ThinkingLevel[] = [
8+
"off",
9+
"minimal",
10+
"low",
11+
"medium",
12+
"high",
13+
"xhigh",
14+
] as const;
15+
16+
/** The value as a ThinkingLevel, or undefined if it isn't one (caller keeps the session's current level). */
17+
export function parseThinkingLevel(value: unknown): ThinkingLevel | undefined {
18+
return THINKING_LEVELS.includes(value as ThinkingLevel) ? (value as ThinkingLevel) : undefined;
19+
}

server/src/api/sessions.ts

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,32 @@
77
* frame sourced from Pi's per-session usage accounting.
88
*/
99
import type { FastifyInstance } from "fastify";
10-
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
1110
import { activePaths } from "../projects.ts";
1211
import { corsResponseHeaders } from "../cors.ts";
1312
import { currentProjectId } from "../scope.ts";
1413
import { toClientFrame, type ClientFrame } from "../agent/events.ts";
15-
import { resolveModel } from "../agent/models.ts";
1614
import { setFusionConfig } from "../agent/fusion-bridge.ts";
15+
import {
16+
pendingInterviewFor,
17+
resolveInterview,
18+
validateAnswer,
19+
type InterviewAnswer,
20+
} from "../agent/interview.ts";
1721
import { setSessionComputeTarget } from "../agent/modal-tool.ts";
22+
import { resolveModel } from "../agent/models.ts";
23+
import {
24+
findSessionFile,
25+
toNotebook,
26+
toShellScript,
27+
} from "../agent/session-export.ts";
28+
import { toHistory } from "../agent/session-history.ts";
1829
import {
1930
createSession,
2031
getModelRegistry,
2132
getSession,
2233
listSessions,
2334
} from "../agent/session-registry.ts";
35+
import { parseThinkingLevel } from "../agent/thinking.ts";
2436
import {
2537
addTurnUsage,
2638
emptySnapshot,
@@ -31,18 +43,6 @@ import {
3143
snapshotMax,
3244
type CostSnapshot,
3345
} from "../cost/ledger.ts";
34-
import {
35-
findSessionFile,
36-
toNotebook,
37-
toShellScript,
38-
} from "../agent/session-export.ts";
39-
import { toHistory } from "../agent/session-history.ts";
40-
import {
41-
pendingInterviewFor,
42-
resolveInterview,
43-
validateAnswer,
44-
type InterviewAnswer,
45-
} from "../agent/interview.ts";
4646

4747
function snapshot(session: { getSessionStats(): { cost: number; tokens: { input: number; output: number; cacheRead: number; total: number } } }): CostSnapshot {
4848
const s = session.getSessionStats();
@@ -321,8 +321,10 @@ export async function registerSessionRoutes(app: FastifyInstance): Promise<void>
321321
}
322322
}
323323
}
324-
if (body.thinkingLevel) {
325-
session.setThinkingLevel(body.thinkingLevel as ThinkingLevel);
324+
if (body.thinkingLevel !== undefined) {
325+
const level = parseThinkingLevel(body.thinkingLevel);
326+
if (level) session.setThinkingLevel(level);
327+
else req.log.warn({ thinkingLevel: body.thinkingLevel }, "ignoring invalid thinkingLevel");
326328
}
327329

328330
// Take over the socket for Server-Sent Events.

server/test/thinking.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { describe, expect, it } from "vitest";
2+
import { parseThinkingLevel, THINKING_LEVELS } from "../src/agent/thinking.ts";
3+
4+
describe("parseThinkingLevel", () => {
5+
it("accepts every Pi level", () => {
6+
expect(THINKING_LEVELS).toEqual(["off", "minimal", "low", "medium", "high", "xhigh"]);
7+
for (const level of THINKING_LEVELS) {
8+
expect(parseThinkingLevel(level)).toBe(level);
9+
}
10+
});
11+
12+
it("rejects unknown strings and non-strings", () => {
13+
expect(parseThinkingLevel("ultra")).toBeUndefined();
14+
expect(parseThinkingLevel("OFF")).toBeUndefined();
15+
expect(parseThinkingLevel("")).toBeUndefined();
16+
expect(parseThinkingLevel(undefined)).toBeUndefined();
17+
expect(parseThinkingLevel(null)).toBeUndefined();
18+
expect(parseThinkingLevel(3)).toBeUndefined();
19+
expect(parseThinkingLevel({ level: "high" })).toBeUndefined();
20+
});
21+
});

web/src/components/chat-tab.tsx

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ import {
3030
type Model,
3131
} from "@/components/model-selector";
3232
import { ComputeSelector, type ModalInstance } from "@/components/compute-selector";
33+
import {
34+
DEFAULT_THINKING_LEVEL,
35+
ThinkingSelector,
36+
type ThinkingLevel,
37+
} from "@/components/thinking-selector";
3338
import { apiFetch } from "@/lib/projects";
3439
import { onChatPrefill } from "@/lib/chat-prefill";
3540
import { buildSkillsContext, type Skill } from "@/components/skills-selector";
@@ -80,9 +85,22 @@ interface QueuedMessage {
8085
files: string[];
8186
/** Selected Modal compute instance id at enqueue time (null = local). */
8287
computeTarget: string | null;
88+
/** Thinking level at enqueue time (null = model doesn't support one). */
89+
thinkingLevel: ThinkingLevel | null;
8390
timestamp: number;
8491
}
8592

93+
/** Models whose runs must NOT carry a thinkingLevel: Ollama models are built
94+
* with reasoning:false (Pi clamps to off) and Fusion rewrites the wire body,
95+
* so a level is meaningless there. Mirrors isOllama in model-selector.tsx. */
96+
function thinkingUnsupported(model: { id: string; provider?: string }): boolean {
97+
return (
98+
model.provider === "Ollama" ||
99+
model.id.startsWith("ollama/") ||
100+
model.id.startsWith("fusion/")
101+
);
102+
}
103+
86104
function BudgetBanner({
87105
state,
88106
totalUsd,
@@ -382,6 +400,9 @@ function ChatInput({
382400
onModelChange,
383401
selectedComputeTarget,
384402
onComputeTargetChange,
403+
thinkingLevel,
404+
onThinkingLevelChange,
405+
thinkingDisabled,
385406
modalConfigured,
386407
onUploadFiles,
387408
allSkills,
@@ -412,6 +433,9 @@ function ChatInput({
412433
onModelChange: (model: Model) => void;
413434
selectedComputeTarget: ModalInstance | null;
414435
onComputeTargetChange: (instance: ModalInstance | null) => void;
436+
thinkingLevel: ThinkingLevel;
437+
onThinkingLevelChange: (level: ThinkingLevel) => void;
438+
thinkingDisabled: boolean;
415439
modalConfigured: boolean;
416440
onUploadFiles: (files: FileList | File[], paths?: string[]) => Promise<string[]>;
417441
allSkills: Skill[];
@@ -686,6 +710,11 @@ function ChatInput({
686710
selected={selectedModel}
687711
onChange={onModelChange}
688712
/>
713+
<ThinkingSelector
714+
selected={thinkingLevel}
715+
onChange={onThinkingLevelChange}
716+
disabled={thinkingDisabled}
717+
/>
689718
<ComputeSelector
690719
selected={selectedComputeTarget}
691720
onChange={onComputeTargetChange}
@@ -910,6 +939,8 @@ export const ChatTab = forwardRef<ChatTabHandle, ChatTabProps>(function ChatTab(
910939
// Per-tab settings
911940
const [selectedModel, setSelectedModel] = useState<Model>(DEFAULT_MODEL);
912941
const [selectedComputeTarget, setSelectedComputeTarget] = useState<ModalInstance | null>(null);
942+
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel>(DEFAULT_THINKING_LEVEL);
943+
const thinkingDisabled = thinkingUnsupported(selectedModel);
913944
const [modalConfigured, setModalConfigured] = useState(false);
914945
const [attachedFiles, setAttachedFiles] = useState<string[]>([]);
915946
const [selectedDbs, setSelectedDbs] = useState<Database[]>([]);
@@ -995,6 +1026,7 @@ export const ChatTab = forwardRef<ChatTabHandle, ChatTabProps>(function ChatTab(
9951026
},
9961027
next.model.fusionConfig,
9971028
next.computeTarget ?? undefined,
1029+
next.thinkingLevel ?? undefined,
9981030
);
9991031
}, 0);
10001032
return () => window.clearTimeout(id);
@@ -1035,11 +1067,12 @@ export const ChatTab = forwardRef<ChatTabHandle, ChatTabProps>(function ChatTab(
10351067
skills: [...selectedSkills],
10361068
files: [...attachedFiles],
10371069
computeTarget: selectedComputeTarget?.id ?? null,
1070+
thinkingLevel: thinkingDisabled ? null : thinkingLevel,
10381071
timestamp: Date.now(),
10391072
},
10401073
]);
10411074
},
1042-
[messageQueue.length, selectedModel, selectedDbs, selectedSkills, attachedFiles, selectedComputeTarget],
1075+
[messageQueue.length, selectedModel, selectedDbs, selectedSkills, attachedFiles, selectedComputeTarget, thinkingDisabled, thinkingLevel],
10431076
);
10441077

10451078
const handleSend = useCallback(
@@ -1058,6 +1091,7 @@ export const ChatTab = forwardRef<ChatTabHandle, ChatTabProps>(function ChatTab(
10581091
},
10591092
selectedModel.fusionConfig,
10601093
selectedComputeTarget?.id,
1094+
thinkingDisabled ? undefined : thinkingLevel,
10611095
);
10621096
const route = routeSubmit(isStreaming, intent);
10631097
if (route === "queue") {
@@ -1090,6 +1124,8 @@ export const ChatTab = forwardRef<ChatTabHandle, ChatTabProps>(function ChatTab(
10901124
selectedDbs,
10911125
selectedSkills,
10921126
attachedFiles,
1127+
thinkingDisabled,
1128+
thinkingLevel,
10931129
],
10941130
);
10951131

@@ -1106,7 +1142,14 @@ export const ChatTab = forwardRef<ChatTabHandle, ChatTabProps>(function ChatTab(
11061142
stop,
11071143
sendQuick: async (prompt: string) => {
11081144
if (budgetState === "exceeded") return;
1109-
await send(prompt, selectedModel.id, undefined, selectedModel.fusionConfig, selectedComputeTarget?.id);
1145+
await send(
1146+
prompt,
1147+
selectedModel.id,
1148+
undefined,
1149+
selectedModel.fusionConfig,
1150+
selectedComputeTarget?.id,
1151+
thinkingDisabled ? undefined : thinkingLevel,
1152+
);
11101153
},
11111154
launchWorkflow: async (prompt, model, suggestedSkills, uploadedFiles) => {
11121155
if (budgetState === "exceeded") return;
@@ -1126,10 +1169,20 @@ export const ChatTab = forwardRef<ChatTabHandle, ChatTabProps>(function ChatTab(
11261169
},
11271170
model.fusionConfig,
11281171
selectedComputeTarget?.id,
1172+
thinkingUnsupported(model) ? undefined : thinkingLevel,
11291173
);
11301174
},
11311175
}),
1132-
[send, stop, budgetState, selectedModel.id, selectedModel.fusionConfig, selectedComputeTarget?.id],
1176+
[
1177+
send,
1178+
stop,
1179+
budgetState,
1180+
selectedModel.id,
1181+
selectedModel.fusionConfig,
1182+
selectedComputeTarget?.id,
1183+
thinkingDisabled,
1184+
thinkingLevel,
1185+
],
11331186
);
11341187

11351188
// Background tabs stay mounted (so streaming + queue auto-send continue,
@@ -1234,6 +1287,9 @@ export const ChatTab = forwardRef<ChatTabHandle, ChatTabProps>(function ChatTab(
12341287
onModelChange={setSelectedModel}
12351288
selectedComputeTarget={selectedComputeTarget}
12361289
onComputeTargetChange={setSelectedComputeTarget}
1290+
thinkingLevel={thinkingLevel}
1291+
onThinkingLevelChange={setThinkingLevel}
1292+
thinkingDisabled={thinkingDisabled}
12371293
modalConfigured={modalConfigured}
12381294
onUploadFiles={uploadFiles}
12391295
allSkills={allSkills}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { render, screen } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import { ThinkingSelector, THINKING_LEVELS } from "./thinking-selector";
5+
6+
describe("ThinkingSelector", () => {
7+
it("shows the current level on the chip", () => {
8+
render(<ThinkingSelector selected="high" onChange={() => {}} />);
9+
expect(screen.getByText("High")).toBeInTheDocument();
10+
});
11+
12+
it("lists all six levels and fires onChange with the picked one", async () => {
13+
const user = userEvent.setup();
14+
const onChange = vi.fn();
15+
render(<ThinkingSelector selected="high" onChange={onChange} />);
16+
await user.click(screen.getByRole("button"));
17+
// Chip + popover row can both show the selected label — use getAllByText.
18+
for (const level of THINKING_LEVELS) {
19+
expect(screen.getAllByText(level.label).length).toBeGreaterThan(0);
20+
}
21+
await user.click(screen.getByText("XHigh"));
22+
expect(onChange).toHaveBeenCalledWith("xhigh");
23+
});
24+
25+
it("when disabled: shows Off, does not open, never fires onChange", async () => {
26+
const user = userEvent.setup();
27+
const onChange = vi.fn();
28+
render(<ThinkingSelector selected="high" onChange={onChange} disabled />);
29+
expect(screen.getByText("Off")).toBeInTheDocument();
30+
await user.click(screen.getByRole("button"));
31+
expect(screen.queryByText("Medium")).not.toBeInTheDocument();
32+
expect(onChange).not.toHaveBeenCalled();
33+
});
34+
});

0 commit comments

Comments
 (0)