Skip to content

Commit 02b86c4

Browse files
authored
feat(app): add reasoning effort to the composer (#148)
* feat(app): add reasoning effort to the composer * fix(ui): group the composer effort menu label
1 parent 24130fe commit 02b86c4

7 files changed

Lines changed: 127 additions & 35 deletions

File tree

‎apps/app/src/composer.tsx‎

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
toast,
1111
useMedia,
1212
} from "@ace/ui";
13-
import type { Listing, ModelRef } from "@ace/host/protocol";
13+
import type { Effort, ModelOption, ModelRef } from "@ace/channel/protocol";
14+
import type { Listing } from "@ace/host/protocol";
1415

1516
import { host } from "./host";
1617
import { images } from "./images";
@@ -20,6 +21,15 @@ const MODES = [
2021
{ id: "ace", name: "Ace", placeholder: "Ask the agent", mention: "ace" },
2122
];
2223
const MENTIONS = [{ name: "ace", avatar: aceAvatar }];
24+
const EFFORTS: Record<Effort, string> = {
25+
off: "Off",
26+
minimal: "Minimal",
27+
low: "Low",
28+
medium: "Medium",
29+
high: "High",
30+
xhigh: "Extra high",
31+
max: "Max",
32+
};
2333
const ACE = /(^|\s)@ace\b/i;
2434
// Channel views remount on navigation; a deliberate mode choice outlives them for this session.
2535
const chosen = new Map<string, string>();
@@ -31,6 +41,7 @@ type Props = {
3141
chat: number;
3242
user: string;
3343
current?: ModelRef;
44+
currentEffort?: Effort;
3445
busy: boolean;
3546
ready: boolean;
3647
draft?: string;
@@ -41,15 +52,26 @@ type Props = {
4152

4253
/** A failed admission keeps the draft and attachments available for retry. */
4354
export function Composer(
44-
{ channel, chat: id, user, current, busy, ready, draft, onDraftLoaded, onSettings, accessory }:
45-
Props,
55+
{
56+
channel,
57+
chat: id,
58+
user,
59+
current,
60+
currentEffort,
61+
busy,
62+
ready,
63+
draft,
64+
onDraftLoaded,
65+
onSettings,
66+
accessory,
67+
}: Props,
4668
) {
4769
const composer = useRef<ChatComposerHandle>(null);
4870
const sending = useRef(false);
4971
const [pending, setPending] = useState(false);
5072
const [attached, setAttached] = useState<Attachment[]>([]);
5173
const [mode, setMode] = useState(chosen.get(channel.id) || "ace");
52-
const [models, setModels] = useState<ModelRef[]>();
74+
const [models, setModels] = useState<ModelOption[]>();
5375
const [modelError, setModelError] = useState<string>();
5476
const status = useSyncExternalStore(host.subscribe, () => host.status);
5577
const settingsVersion = useSyncExternalStore(host.subscribe, () => host.settingsVersion);
@@ -62,7 +84,7 @@ export function Composer(
6284
if (status !== "open" || mode !== "ace") return;
6385
let active = true;
6486
// Ask the channel itself: a hosted channel uses the service's credentials, not its workspace's.
65-
host.channel<ModelRef[]>(channel.id, { op: "models" }).then(
87+
host.channel<ModelOption[]>(channel.id, { op: "models" }).then(
6688
(models) => {
6789
if (!active) return;
6890
setModels(models);
@@ -80,6 +102,19 @@ export function Composer(
80102
}, [channel.id, mode, status, settingsVersion]);
81103
const [picked, setPicked] = useState<string>();
82104
const model = picked || (current ? key(current) : "auto");
105+
const [pickedEffort, setPickedEffort] = useState<Effort>();
106+
const levels = models?.find((value) => key(value) === model)?.efforts;
107+
const requestedEffort = pickedEffort
108+
|| (current && key(current) === model ? currentEffort : undefined) || "off";
109+
const effort = requestedEffort === "off" || levels?.includes(requestedEffort)
110+
? requestedEffort
111+
: "off";
112+
const efforts = levels && levels.some((level) => level !== "off")
113+
? (levels.includes("off") ? levels : ["off" as const, ...levels]).map((id) => ({
114+
id,
115+
name: id === "off" && !levels.includes("off") ? "Default" : EFFORTS[id],
116+
}))
117+
: undefined;
83118
const choices = (models || []).map((value) => ({
84119
id: key(value),
85120
name: value.modelId,
@@ -106,6 +141,7 @@ export function Composer(
106141
const [provider, ...rest] = model.split("/");
107142
await host.channel(channel.id, {
108143
op: "ask",
144+
...(levels ? { effort } : {}),
109145
chat: id,
110146
author: user,
111147
text: body,
@@ -164,7 +200,13 @@ export function Composer(
164200
mentions={MENTIONS}
165201
models={choices}
166202
model={model}
167-
onModelChange={(value) => setPicked(value === "auto" ? undefined : value)}
203+
onModelChange={(value) => {
204+
setPicked(value === "auto" ? undefined : value);
205+
setPickedEffort(undefined);
206+
}}
207+
efforts={efforts}
208+
effort={effort}
209+
onEffortChange={(value) => setPickedEffort(value as Effort)}
168210
attachments={attached}
169211
onAttachmentsChange={setAttached}
170212
busy={busy}

‎apps/app/src/conversation.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export function Conversation(
101101
chat={id}
102102
user={user}
103103
current={model}
104+
currentEffort={chat?.effort}
104105
busy={transcript.busy}
105106
ready={transcript.live && channel.state !== "archived"}
106107
draft={draft}

‎docs/architecture.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,12 @@ is one pi conversation in that Session.
6060
its runtime's default on its first invocation: the local host's current preference, or an available
6161
model on the hosting service. Availability is checked before admitting the input, so a rejected
6262
invocation queues no work and leaves its draft editable.
63-
- **Model per run.** The chat's model is set when a run is admitted. Changing it while a run is
64-
active is rejected rather than changing the active run's later turns.
63+
- **Model and effort per run.** The chat's model and reasoning effort are set when a run is
64+
admitted, stored in pi's agent configuration. Changing either while a run is active is rejected
65+
rather than changing its later turns. Admissions are serialized per chat. The composer lists
66+
levels from the channel's model catalog; Default leaves reasoning to always-thinking providers,
67+
while Off disables it where supported. Switching models resets the composer's effort to that
68+
model's default until a level is chosen.
6569
- **Subagents are child chats.** A subagent's chat is owned by the parent's tool task, so killing
6670
the parent kills the child. Agents in different channels only exchange messages.
6771
- **Lanes are Git worktrees** created by the chat's agent for each unit of work. A chat's working

‎packages/channel/src/channel.ts‎

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Models } from "@earendil-works/pi-ai";
1+
import { getSupportedThinkingLevels, type Models } from "@earendil-works/pi-ai";
22
import { BACKGROUND_CONTEXT as context } from "@earendil-works/chord/context";
33
import {
44
AgentDoc,
@@ -36,6 +36,7 @@ import type {
3636
ChannelInfo,
3737
Chat,
3838
ChatId,
39+
Effort,
3940
Event,
4041
Metadata,
4142
ModelRef,
@@ -88,6 +89,7 @@ export class Channel {
8889
#log: Log;
8990
#metadata: Metadata;
9091
#active = 0;
92+
#admissions = new Map<ChatId, Promise<unknown>>();
9193
#listeners = new Set<{ chat: ChatId; send: Send }>();
9294

9395
private constructor(options: Options, harness: Harness, log: Log, value: Metadata) {
@@ -237,6 +239,7 @@ export class Channel {
237239
if (window && used) chat.context = { used, window };
238240
if (record.owner) chat.parent = record.owner.conversationId;
239241
if (agent?.model) chat.model = agent.model;
242+
chat.effort = agent?.thinkingLevel || "off";
240243
if (lane) chat.lane = lane;
241244
return chat;
242245
}));
@@ -318,6 +321,19 @@ export class Channel {
318321
}
319322

320323
async #ask(request: Extract<Request, { op: "ask" }>) {
324+
const chat = request.chat || ROOT_CONVERSATION_ID;
325+
const previous = this.#admissions.get(chat) || Promise.resolve();
326+
// Selection and submission must stay together across concurrent clients.
327+
const pending = previous.catch(() => {}).then(() => this.#admit(request));
328+
this.#admissions.set(chat, pending);
329+
try {
330+
return await pending;
331+
} finally {
332+
if (this.#admissions.get(chat) === pending) this.#admissions.delete(chat);
333+
}
334+
}
335+
336+
async #admit(request: Extract<Request, { op: "ask" }>) {
321337
this.#author(request.author);
322338
room.checkImages(request.images);
323339
const settings = await this.#harness.snapshot(SettingsDoc, context);
@@ -326,7 +342,7 @@ export class Channel {
326342
throw new Error("Only the owner can invoke agents here");
327343
}
328344
const conversation = await this.#conversation(request.chat);
329-
await this.#select(conversation, request.model);
345+
await this.#select(conversation, request.model, request.effort);
330346
const submission = await conversation.submit({
331347
type: "input",
332348
content: room.content(request.author, request.text, request.images),
@@ -336,19 +352,33 @@ export class Channel {
336352
return { submission: submission.id };
337353
}
338354

339-
/** A model applies to a whole run, so it cannot change while one is active. */
340-
async #select(conversation: Conversation, requested: ModelRef | undefined) {
341-
const current = (await conversation.agent(context)).model;
355+
/** Model and effort apply to a whole run, never just its later turns. */
356+
async #select(conversation: Conversation, requested: ModelRef | undefined, effort?: Effort) {
357+
const agent = await conversation.agent(context);
358+
const current = agent.model;
342359
const model = requested || current || await (
343360
this.#options.defaultModel?.() || choose(this.#options.models)
344361
);
345-
if (!this.#options.models.getModel(model.provider, model.modelId)) {
362+
const selected = this.#options.models.getModel(model.provider, model.modelId);
363+
if (!selected) {
346364
throw new Error(`Unknown model ${model.provider}/${model.modelId}`);
347365
}
348-
const same = current?.provider === model.provider && current.modelId === model.modelId;
366+
const levels = getSupportedThinkingLevels(selected);
367+
if (effort !== undefined && effort !== "off" && !levels.includes(effort)) {
368+
throw new Error(
369+
`Unsupported reasoning effort ${effort} for ${model.provider}/${model.modelId}`,
370+
);
371+
}
372+
// Pi's "off" means omit reasoning, allowing provider defaults on always-thinking models.
373+
const thinkingLevel = effort
374+
|| (levels.includes(agent.thinkingLevel) ? agent.thinkingLevel : "off");
375+
const sameModel = current?.provider === model.provider && current.modelId === model.modelId;
376+
const same = sameModel && thinkingLevel === agent.thinkingLevel;
349377
const live = await this.#harness.snapshot(LiveDoc, conversation.id, context);
350378
if (!same && live?.run) {
351-
throw new Error("The chat is busy; stop it or wait before changing its model");
379+
throw new Error(
380+
"The chat is busy; stop it or wait before changing its model or reasoning effort",
381+
);
352382
}
353383
const available = await this.#options.models.getAvailable(model.provider);
354384
if (!available.some((value) => value.id === model.modelId)) {
@@ -357,7 +387,7 @@ export class Channel {
357387
);
358388
}
359389
if (same) return;
360-
await conversation.configure({ model }, context);
390+
await conversation.configure({ model, thinkingLevel }, context);
361391
}
362392

363393
async #chat(request: Extract<Request, { op: "chat" }>) {

‎packages/channel/src/models.ts‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,22 @@
1-
import type { Models } from "@earendil-works/pi-ai";
1+
import { getSupportedThinkingLevels, type Models } from "@earendil-works/pi-ai";
22

3-
import type { ModelRef } from "./protocol";
3+
import type { ModelOption, ModelRef } from "./protocol";
44

55
const PREFERRED = ["anthropic/claude-opus-5-5", "openai/gpt-6-astra"];
66

77
/** One inaccessible provider must not hide another provider's usable models. */
8-
export async function available(models: Models): Promise<ModelRef[]> {
8+
export async function available(models: Models): Promise<ModelOption[]> {
99
const results = await Promise.allSettled(
1010
models.getProviders().map((provider) => models.getAvailable(provider.id)),
1111
);
1212
const found = results.flatMap((result) => result.status === "fulfilled" ? result.value : []);
1313
const failed = results.find((result) => result.status === "rejected");
1414
if (!found.length && failed?.status === "rejected") throw failed.reason;
15-
return found.map(({ provider, id }) => ({ provider, modelId: id }));
15+
return found.map((model) => ({
16+
provider: model.provider,
17+
modelId: model.id,
18+
efforts: getSupportedThinkingLevels(model),
19+
}));
1620
}
1721

1822
/** Resolve an automatic choice only when admitting an agent invocation. */
@@ -26,5 +30,5 @@ export async function choose(models: Models): Promise<ModelRef> {
2630
"Add a provider key for this channel before invoking an agent. Chat is still available.",
2731
);
2832
}
29-
return selected;
33+
return { provider: selected.provider, modelId: selected.modelId };
3034
}

‎packages/channel/src/protocol.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1+
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
2+
13
/** The wire contract between a channel and its clients: one JSON value per line. */
24

35
export type ModelRef = { provider: string; modelId: string };
6+
/** "off" leaves always-thinking providers at their default; otherwise disables reasoning. */
7+
export type Effort = ModelThinkingLevel;
8+
export type ModelOption = ModelRef & { efforts: Effort[] };
49

510
/** Cumulative usage for one chat, including compaction and usage reported by tools. */
611
export type Usage = {
@@ -35,6 +40,7 @@ export type Chat = {
3540
parent?: ChatId;
3641
busy: boolean;
3742
model?: ModelRef;
43+
effort?: Effort;
3844
lane?: string;
3945
/** Older hosts may not report usage. */
4046
usage?: Usage;
@@ -146,6 +152,7 @@ export type Request =
146152
text: string;
147153
images?: Image[];
148154
model?: ModelRef;
155+
effort?: Effort;
149156
requestId?: string;
150157
}
151158
| { op: "chat"; author: string; model?: ModelRef }

‎packages/ui/src/components/composer/chat-composer.tsx‎

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
import {
3838
DropdownMenu,
3939
DropdownMenuContent,
40+
DropdownMenuGroup,
4041
DropdownMenuItem,
4142
DropdownMenuLabel,
4243
DropdownMenuSeparator,
@@ -1405,6 +1406,7 @@ function ComposerEffort({ state }: { state: ComposerState }) {
14051406
<DropdownMenuTrigger
14061407
render={
14071408
<Toolbar.Button
1409+
aria-label={`Reasoning effort: ${selected.name}`}
14081410
render={
14091411
<Button
14101412
variant="ghost"
@@ -1419,19 +1421,21 @@ function ComposerEffort({ state }: { state: ComposerState }) {
14191421
<IconChevronDownMicro className="size-3 transition-transform duration-150 group-data-[popup-open]/button:scale-y-[-1]" />
14201422
</DropdownMenuTrigger>
14211423
<DropdownMenuContent align="start" sideOffset={6} className="min-w-36">
1422-
<DropdownMenuLabel>Reasoning effort</DropdownMenuLabel>
1423-
{efforts?.map(entry => (
1424-
<DropdownMenuItem
1425-
key={entry.id}
1426-
onClick={() => {
1427-
onEffortChange(entry.id);
1428-
view.current?.focus();
1429-
}}
1430-
>
1431-
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
1432-
{entry.id === effort && <IconCheckMicro className="size-3" />}
1433-
</DropdownMenuItem>
1434-
))}
1424+
<DropdownMenuGroup>
1425+
<DropdownMenuLabel>Reasoning effort</DropdownMenuLabel>
1426+
{efforts?.map(entry => (
1427+
<DropdownMenuItem
1428+
key={entry.id}
1429+
onClick={() => {
1430+
onEffortChange(entry.id);
1431+
view.current?.focus();
1432+
}}
1433+
>
1434+
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
1435+
{entry.id === effort && <IconCheckMicro className="size-3" />}
1436+
</DropdownMenuItem>
1437+
))}
1438+
</DropdownMenuGroup>
14351439
</DropdownMenuContent>
14361440
</DropdownMenu>
14371441
);

0 commit comments

Comments
 (0)