@agentprojectcontext/apx 1.73.0 → 1.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/package.json +1 -1
  2. package/src/core/channels/telegram/ask-callbacks.js +4 -0
  3. package/src/core/channels/telegram/dispatch.js +13 -3
  4. package/src/core/channels/telegram/reply.js +19 -7
  5. package/src/core/net/ipv4-first.js +32 -0
  6. package/src/core/stores/messages.js +19 -2
  7. package/src/host/daemon/api/super-agent.js +35 -5
  8. package/src/host/daemon/api/voice.js +20 -1
  9. package/src/host/daemon/index.js +2 -0
  10. package/src/host/daemon/plugins/desktop/index.js +13 -2
  11. package/src/interfaces/cli/commands/exec.js +81 -3
  12. package/src/interfaces/web/dist/assets/index-BxYXJEtf.js +819 -0
  13. package/src/interfaces/web/dist/assets/index-BxYXJEtf.js.map +1 -0
  14. package/src/interfaces/web/dist/assets/index-COrRuBp1.css +1 -0
  15. package/src/interfaces/web/dist/index.html +2 -2
  16. package/src/interfaces/web/package-lock.json +327 -324
  17. package/src/interfaces/web/src/components/chat/ContextBar.tsx +83 -28
  18. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +23 -1
  19. package/src/interfaces/web/src/hooks/useChat.ts +37 -5
  20. package/src/interfaces/web/src/i18n/en.ts +3 -0
  21. package/src/interfaces/web/src/i18n/es.ts +3 -0
  22. package/src/interfaces/web/src/lib/api/agents.ts +2 -2
  23. package/src/interfaces/web/src/screens/base/LogsTab.tsx +19 -0
  24. package/src/interfaces/web/src/screens/project/AgentBrainGraph.tsx +10 -1
  25. package/src/interfaces/web/src/types/daemon.ts +11 -0
  26. package/src/interfaces/web/dist/assets/index-CUeIhw7z.css +0 -1
  27. package/src/interfaces/web/dist/assets/index-D2b7Sqvg.js +0 -819
  28. package/src/interfaces/web/dist/assets/index-D2b7Sqvg.js.map +0 -1
@@ -1,7 +1,8 @@
1
1
  import { useMemo, useState } from "react";
2
- import { ChevronDown, FilePen, Gauge, Wrench } from "lucide-react";
2
+ import { Bot, ChevronDown, FilePen, Gauge, Wrench } from "lucide-react";
3
3
  import { cn } from "../../lib/cn";
4
4
  import { FILE_TOOLS } from "./ToolCall";
5
+ import { t } from "../../i18n";
5
6
  import type { ChatMsg, ToolPart } from "../../hooks/useChat";
6
7
 
7
8
  interface ChangedFile {
@@ -9,6 +10,18 @@ interface ChangedFile {
9
10
  tool: string;
10
11
  }
11
12
 
13
+ /** One (agent, model) pair that contributed to this conversation, with what it
14
+ * spent. A turn where the router fell back mid-conversation produces two rows
15
+ * for the same agent — that's the point: you see WHICH model cost what. */
16
+ interface ActorUsage {
17
+ key: string;
18
+ agent?: string;
19
+ model?: string;
20
+ inTok: number;
21
+ outTok: number;
22
+ turns: number;
23
+ }
24
+
12
25
  // Pull the file path out of a write_file/edit_file invocation's args.
13
26
  function filePathOf(args?: Record<string, unknown>): string | undefined {
14
27
  if (!args) return undefined;
@@ -18,26 +31,37 @@ function filePathOf(args?: Record<string, unknown>): string | undefined {
18
31
 
19
32
  /**
20
33
  * Compact, opencode-style strip summarising the conversation: token usage,
21
- * tool count, and an expandable list of files the agent wrote or edited.
22
- * Renders nothing until the agent has actually done something.
34
+ * tool count, an expandable list of files the agent wrote or edited, and the
35
+ * per-actor breakdown (which agent answered on which model, and what each
36
+ * spent). Renders nothing until the agent has actually done something.
23
37
  */
24
38
  export function ContextBar({ msgs }: { msgs: ChatMsg[] }) {
25
39
  const [open, setOpen] = useState(false);
26
40
 
27
- const { inTok, outTok, toolCount, changed, model } = useMemo(() => {
41
+ const { inTok, outTok, toolCount, changed, actors } = useMemo(() => {
28
42
  let inTok = 0;
29
43
  let outTok = 0;
30
44
  let toolCount = 0;
31
- let model: string | undefined;
32
45
  const seen = new Set<string>();
33
46
  const changed: ChangedFile[] = [];
47
+ const byActor = new Map<string, ActorUsage>();
34
48
  for (const m of msgs) {
35
49
  if (m.role !== "assistant") continue;
36
- if (m.usage) {
37
- inTok += m.usage.input_tokens || 0;
38
- outTok += m.usage.output_tokens || 0;
50
+ const mIn = m.usage?.input_tokens || 0;
51
+ const mOut = m.usage?.output_tokens || 0;
52
+ inTok += mIn;
53
+ outTok += mOut;
54
+ if (m.agent || m.model) {
55
+ const key = `${m.agent || ""}::${m.model || ""}`;
56
+ const prev = byActor.get(key);
57
+ if (prev) {
58
+ prev.inTok += mIn;
59
+ prev.outTok += mOut;
60
+ prev.turns += 1;
61
+ } else {
62
+ byActor.set(key, { key, agent: m.agent, model: m.model, inTok: mIn, outTok: mOut, turns: 1 });
63
+ }
39
64
  }
40
- if (m.model) model = m.model;
41
65
  for (const part of m.parts) {
42
66
  if (part.kind !== "tool") continue;
43
67
  toolCount += 1;
@@ -50,20 +74,21 @@ export function ContextBar({ msgs }: { msgs: ChatMsg[] }) {
50
74
  }
51
75
  }
52
76
  }
53
- return { inTok, outTok, toolCount, changed, model };
77
+ return { inTok, outTok, toolCount, changed, actors: [...byActor.values()] };
54
78
  }, [msgs]);
55
79
 
56
80
  const totalTok = inTok + outTok;
57
- if (totalTok === 0 && toolCount === 0) return null;
81
+ const expandable = changed.length > 0 || actors.length > 1;
82
+ if (totalTok === 0 && toolCount === 0 && actors.length === 0) return null;
58
83
 
59
84
  return (
60
85
  <div className="shrink-0 border-t border-border bg-card/40 text-[11px]">
61
86
  <button
62
87
  type="button"
63
- onClick={() => changed.length > 0 && setOpen((v) => !v)}
88
+ onClick={() => expandable && setOpen((v) => !v)}
64
89
  className={cn(
65
90
  "flex w-full items-center gap-3 px-4 py-1.5 text-muted-foreground",
66
- changed.length > 0 && "hover:text-foreground",
91
+ expandable && "hover:text-foreground",
67
92
  )}
68
93
  >
69
94
  <span className="flex items-center gap-1">
@@ -79,27 +104,57 @@ export function ContextBar({ msgs }: { msgs: ChatMsg[] }) {
79
104
  )}
80
105
  {changed.length > 0 && (
81
106
  <span className="flex items-center gap-1 text-violet-400">
82
- <FilePen size={12} /> {changed.length} archivos
107
+ <FilePen size={12} /> {changed.length} {t("chat_ui.ctx_files")}
83
108
  </span>
84
109
  )}
85
- {model && <span className="ml-auto font-mono text-muted-foreground/70">{model}</span>}
86
- {changed.length > 0 && (
110
+ {/* One actor show it inline. Several → say how many and let the user
111
+ expand for the split. */}
112
+ {actors.length === 1 && (
113
+ <span className="ml-auto truncate font-mono text-muted-foreground/70">
114
+ {[actors[0].agent, actors[0].model].filter(Boolean).join(" · ")}
115
+ </span>
116
+ )}
117
+ {actors.length > 1 && (
118
+ <span className="ml-auto flex items-center gap-1 text-sky-400">
119
+ <Bot size={12} /> {t("chat_ui.ctx_actors", { n: actors.length })}
120
+ </span>
121
+ )}
122
+ {expandable && (
87
123
  <ChevronDown className={cn("size-3 shrink-0 transition-transform", open && "rotate-180")} />
88
124
  )}
89
125
  </button>
90
126
 
91
- {open && changed.length > 0 && (
92
- <ul className="max-h-40 space-y-0.5 overflow-y-auto border-t border-border/60 px-4 py-2">
93
- {changed.map((f) => (
94
- <li key={f.path} className="flex items-center gap-2 font-mono text-[11px]">
95
- <FilePen size={11} className="shrink-0 text-violet-400" />
96
- <span className="truncate">{f.path}</span>
97
- <span className="ml-auto shrink-0 text-[10px] text-muted-foreground/60">
98
- {f.tool === "write_file" ? "write" : "edit"}
99
- </span>
100
- </li>
101
- ))}
102
- </ul>
127
+ {open && (
128
+ <div className="max-h-52 space-y-2 overflow-y-auto border-t border-border/60 px-4 py-2">
129
+ {actors.length > 1 && (
130
+ <ul className="space-y-0.5">
131
+ {actors.map((a) => (
132
+ <li key={a.key} className="flex items-center gap-2 text-[11px]">
133
+ <Bot size={11} className="shrink-0 text-sky-400" />
134
+ <span className="shrink-0 font-medium text-emerald-300">{a.agent || ""}</span>
135
+ <span className="truncate font-mono text-muted-foreground/70">{a.model || "—"}</span>
136
+ <span className="ml-auto shrink-0 font-mono text-[10px] text-muted-foreground/60">
137
+ {fmt(a.inTok + a.outTok)} tok ({fmt(a.inTok)}↑ / {fmt(a.outTok)}↓) ·{" "}
138
+ {t("chat_ui.ctx_turns", { n: a.turns })}
139
+ </span>
140
+ </li>
141
+ ))}
142
+ </ul>
143
+ )}
144
+ {changed.length > 0 && (
145
+ <ul className="space-y-0.5">
146
+ {changed.map((f) => (
147
+ <li key={f.path} className="flex items-center gap-2 font-mono text-[11px]">
148
+ <FilePen size={11} className="shrink-0 text-violet-400" />
149
+ <span className="truncate">{f.path}</span>
150
+ <span className="ml-auto shrink-0 text-[10px] text-muted-foreground/60">
151
+ {f.tool === "write_file" ? "write" : "edit"}
152
+ </span>
153
+ </li>
154
+ ))}
155
+ </ul>
156
+ )}
157
+ </div>
103
158
  )}
104
159
  </div>
105
160
  );
@@ -105,9 +105,31 @@ export function MessageBubble({ msg, isLast, isAskAnswer, onCopy }: Props) {
105
105
  </div>
106
106
  )}
107
107
 
108
+ {/* Attribution: who answered and on which engine. Always visible (not
109
+ hover-gated) — in a thread where several agents/models take turns,
110
+ this is the only way to tell them apart at a glance. */}
111
+ {!mine && (msg.agent || msg.model) && (
112
+ <div className="flex flex-wrap items-center gap-1 text-[10px]">
113
+ {msg.agent && (
114
+ <span className="rounded bg-emerald-500/15 px-1 py-0.5 font-medium text-emerald-300">
115
+ {msg.agent}
116
+ </span>
117
+ )}
118
+ {msg.model && (
119
+ <span className="rounded border border-border px-1 py-0.5 font-mono text-muted-foreground">
120
+ {msg.model}
121
+ </span>
122
+ )}
123
+ </div>
124
+ )}
125
+
108
126
  <div className="flex items-center gap-2 text-[10px] text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100">
109
127
  <span>{formatTs(msg.ts)}</span>
110
- {!mine && msg.model && <span className="font-mono">· {msg.model}</span>}
128
+ {!mine && msg.usage && (msg.usage.input_tokens || msg.usage.output_tokens) ? (
129
+ <span className="font-mono">
130
+ · {(msg.usage.input_tokens || 0) + (msg.usage.output_tokens || 0)} tok
131
+ </span>
132
+ ) : null}
111
133
  {!mine && hasTools && (
112
134
  <span>· {t("shared_ui.tools_count", { n: msg.parts.filter((p) => p.kind === "tool").length })}</span>
113
135
  )}
@@ -29,6 +29,10 @@ export interface ChatMsg {
29
29
  pending?: boolean;
30
30
  /** Model that produced an assistant turn (after routing). */
31
31
  model?: string;
32
+ /** Who answered: display name of the agent/persona (Roby, a project agent…). */
33
+ agent?: string;
34
+ /** Stable id of that actor (super_agent | agent slug). Turns are split on it. */
35
+ agentId?: string;
32
36
  /** Token accounting from the `final` event. */
33
37
  usage?: ChatUsage;
34
38
  /** Operational notes (engine fallbacks, retries, suppressions). */
@@ -135,19 +139,31 @@ function isErrorResult(result: unknown): boolean {
135
139
  * single assistant bubble with interleaved text + tool parts — mirroring how a
136
140
  * live streamed turn is shaped, so tool executions render the same on reload as
137
141
  * they did in real time. Persisted rows carry no live status, so it's derived
138
- * from the stored result (error → "error", else "done"). */
142
+ * from the stored result (error → "error", else "done").
143
+ *
144
+ * A change of ACTOR also breaks the bubble: if Roby answers and then a project
145
+ * agent does, they're two turns, not one — otherwise the footer would credit
146
+ * the whole block to whichever one happened to be last. Token usage is summed
147
+ * across the rows of a turn (streamed channels write several agent rows and
148
+ * only the final one carries `usage`). */
139
149
  function threadToChatMsgs(messages: ConversationMessage[]): ChatMsg[] {
140
150
  const out: ChatMsg[] = [];
141
151
  let turn: ChatMsg | null = null;
152
+ let turnActor: string | undefined;
142
153
  let toolSeq = 0;
143
154
  for (const m of messages) {
144
155
  const ts = m.ts || new Date().toISOString();
145
156
  if (m.role === "user") {
146
157
  turn = null;
158
+ turnActor = undefined;
147
159
  out.push({ role: "user", parts: userPart(m.content), ts });
148
160
  } else if (m.role === "assistant" || m.role === "tool") {
149
- if (!turn) {
161
+ // Tool rows inherit the current actor (they're logged by whoever is
162
+ // running); only assistant rows can start a new one.
163
+ const actor = m.role === "assistant" ? m.agent : turnActor;
164
+ if (!turn || (m.role === "assistant" && actor !== turnActor)) {
150
165
  turn = { role: "assistant", parts: [], ts };
166
+ turnActor = actor;
151
167
  out.push(turn);
152
168
  }
153
169
  if (m.role === "tool") {
@@ -159,8 +175,17 @@ function threadToChatMsgs(messages: ConversationMessage[]): ChatMsg[] {
159
175
  result: m.result,
160
176
  status: isErrorResult(m.result) ? "error" : "done",
161
177
  });
162
- } else if (m.content) {
163
- turn.parts.push({ kind: "text", text: m.content });
178
+ } else {
179
+ if (m.agent) turn.agentId = m.agent;
180
+ if (m.agent_name) turn.agent = m.agent_name;
181
+ if (m.model) turn.model = m.model;
182
+ if (m.usage) {
183
+ turn.usage = {
184
+ input_tokens: (turn.usage?.input_tokens || 0) + (m.usage.input_tokens || 0),
185
+ output_tokens: (turn.usage?.output_tokens || 0) + (m.usage.output_tokens || 0),
186
+ };
187
+ }
188
+ if (m.content) turn.parts.push({ kind: "text", text: m.content });
164
189
  }
165
190
  }
166
191
  // system/compact rows are context-only; not rendered in the thread viewer.
@@ -256,7 +281,11 @@ export function applyStreamEvent(turn: ChatMsg, ev: ChatStreamEvent): ChatMsg {
256
281
  ...turn,
257
282
  pending: false,
258
283
  usage: ev.result?.usage ?? turn.usage,
259
- model: turn.model ?? ev.result?.name,
284
+ // `model` is the engine (from model_start/model_routed, or the final
285
+ // event); `name` is the persona that answered — they are not the same
286
+ // thing and must not fall back to each other.
287
+ model: turn.model ?? ev.result?.model,
288
+ agent: turn.agent ?? ev.result?.name,
260
289
  parts:
261
290
  ev.result?.text && !turn.parts.some((p) => p.kind === "text")
262
291
  ? [...turn.parts, { kind: "text", text: ev.result.text }]
@@ -347,6 +376,9 @@ export function useChat(pid: string, onError?: (msg: string) => void): UseChatRe
347
376
  ...m,
348
377
  pending: false,
349
378
  model: out.engine,
379
+ agent: opts.agentSlug,
380
+ agentId: opts.agentSlug,
381
+ usage: out.usage,
350
382
  parts: [{ kind: "text", text: out.text }],
351
383
  }));
352
384
  } catch (e) {
@@ -1211,6 +1211,9 @@ export const en = {
1211
1211
  send: "Send",
1212
1212
  pick_model: "Pick model (or Auto)",
1213
1213
  insert_variable: "Insert variable",
1214
+ ctx_files: "files",
1215
+ ctx_actors: "{n} agents/models",
1216
+ ctx_turns: "{n} turns",
1214
1217
  },
1215
1218
 
1216
1219
  sidebar_ui: {
@@ -1209,6 +1209,9 @@ export const es = {
1209
1209
  send: "Enviar",
1210
1210
  pick_model: "Elegir modelo (o Auto)",
1211
1211
  insert_variable: "Insertar variable",
1212
+ ctx_files: "archivos",
1213
+ ctx_actors: "{n} agentes/modelos",
1214
+ ctx_turns: "{n} turnos",
1212
1215
  },
1213
1216
 
1214
1217
  sidebar_ui: {
@@ -1,5 +1,5 @@
1
1
  import { http } from "../http";
2
- import type { AgentDetail, AgentEntry } from "../../types/daemon";
2
+ import type { AgentDetail, AgentEntry, ChatUsage } from "../../types/daemon";
3
3
 
4
4
  export const Agents = {
5
5
  list: (pid: string, opts?: { stats?: boolean }) =>
@@ -12,7 +12,7 @@ export const Agents = {
12
12
  remove: (pid: string, slug: string) =>
13
13
  http.del<{ ok: boolean }>(`/projects/${pid}/agents/${encodeURIComponent(slug)}`),
14
14
  chat: (pid: string, slug: string, body: { prompt: string; conversation_id?: string; model?: string; channel?: string }) =>
15
- http.post<{ conversation_id: string; text: string; usage?: unknown; engine: string }>(
15
+ http.post<{ conversation_id: string; text: string; usage?: ChatUsage; engine: string }>(
16
16
  `/projects/${pid}/agents/${encodeURIComponent(slug)}/chat`,
17
17
  body,
18
18
  ),
@@ -23,10 +23,27 @@ function actorLabel(m: MessageEntry): string {
23
23
  return m.agent_slug || m.actor_id || m.author || m.actor_kind || "—";
24
24
  }
25
25
 
26
+ /** Model that produced this record — persisted in meta by every channel that
27
+ * runs an agent turn. Absent on user/system rows and on pre-1.74 history. */
28
+ function modelOf(m: MessageEntry): string | null {
29
+ const v = m.meta?.model;
30
+ return typeof v === "string" && v ? v : null;
31
+ }
32
+
33
+ /** Total tokens for this record, when the channel recorded usage. */
34
+ function tokensOf(m: MessageEntry): number | null {
35
+ const u = m.meta?.usage as { input_tokens?: number; output_tokens?: number } | undefined;
36
+ if (!u || typeof u !== "object") return null;
37
+ const total = (u.input_tokens || 0) + (u.output_tokens || 0);
38
+ return total > 0 ? total : null;
39
+ }
40
+
26
41
  function LogRow({ m }: { m: MessageEntry }) {
27
42
  const [expanded, setExpanded] = useState(false);
28
43
  const long = (m.body?.length || 0) > CLAMP;
29
44
  const shown = !long || expanded ? m.body : `${m.body.slice(0, CLAMP)}…`;
45
+ const model = modelOf(m);
46
+ const tokens = tokensOf(m);
30
47
  return (
31
48
  <li className="flex items-start gap-3 rounded-md border border-border bg-muted/30 px-3 py-2">
32
49
  <span className="mt-0.5 shrink-0">
@@ -40,6 +57,8 @@ function LogRow({ m }: { m: MessageEntry }) {
40
57
  <Badge tone="info">{m.channel}</Badge>
41
58
  {m.type && <Badge>{m.type}</Badge>}
42
59
  <span className="font-medium text-foreground">{actorLabel(m)}</span>
60
+ {model && <span className="font-mono text-[11px] text-sky-400/90">{model}</span>}
61
+ {tokens !== null && <span className="font-mono text-[11px]">{tokens} tok</span>}
43
62
  </div>
44
63
  {m.body && (
45
64
  <p className="mt-1 whitespace-pre-wrap break-words text-xs">{shown}</p>
@@ -78,6 +78,9 @@ export function BrainGraph({
78
78
  const linksRef = useRef<SimLink[]>([]);
79
79
  const dragRef = useRef<SimNode | null>(null);
80
80
  const panRef = useRef<{ x: number; y: number } | null>(null);
81
+ // Press bookkeeping so a drag is never mistaken for a click (which navigates).
82
+ const downRef = useRef<{ x: number; y: number } | null>(null);
83
+ const movedRef = useRef(false);
81
84
  const viewRef = useRef({ tx: 0, ty: 0, k: 1 });
82
85
  const fitRef = useRef<() => void>(() => {});
83
86
  const [, setVersion] = useState(0);
@@ -194,6 +197,8 @@ export function BrainGraph({
194
197
  if (roleOf(n) === "core") return;
195
198
  e.stopPropagation();
196
199
  dragRef.current = n;
200
+ downRef.current = { x: e.clientX, y: e.clientY };
201
+ movedRef.current = false;
197
202
  (e.target as Element).setPointerCapture?.(e.pointerId);
198
203
  simRef.current?.alphaTarget(0.3).restart();
199
204
  };
@@ -203,6 +208,8 @@ export function BrainGraph({
203
208
  };
204
209
  const onMove = (e: React.PointerEvent) => {
205
210
  if (dragRef.current) {
211
+ const d = downRef.current;
212
+ if (d && !movedRef.current && Math.hypot(e.clientX - d.x, e.clientY - d.y) > 4) movedRef.current = true;
206
213
  const w = worldFromClient(e.clientX, e.clientY);
207
214
  dragRef.current.fx = w.x; dragRef.current.fy = w.y;
208
215
  return;
@@ -222,6 +229,8 @@ export function BrainGraph({
222
229
  panRef.current = null;
223
230
  simRef.current?.alphaTarget(0);
224
231
  };
232
+ // Native click fires reliably on pointerup; skip it when the press was a drag.
233
+ const onNodeClickGuarded = (n: SimNode) => () => { if (!movedRef.current) pick(n); };
225
234
 
226
235
  const simNodes = nodesRef.current;
227
236
  const links = linksRef.current;
@@ -342,7 +351,7 @@ export function BrainGraph({
342
351
  const showLabel = isHub || isSel || !hideLeafLabels;
343
352
  return (
344
353
  <g key={n.id} transform={`translate(${n.x},${n.y})`} className="cursor-grab active:cursor-grabbing"
345
- onPointerDown={onNodeDown(n)} onClick={() => pick(n)}>
354
+ onPointerDown={onNodeDown(n)} onClick={onNodeClickGuarded(n)}>
346
355
  <circle r={r} fill={color} filter="url(#brain-glow)" opacity={0.3}>
347
356
  <animate attributeName="r" values={`${r};${r + 6};${r}`} dur={`${beat}s`} begin={begin} repeatCount="indefinite" />
348
357
  <animate attributeName="opacity" values="0.32;0.08;0.32" dur={`${beat}s`} begin={begin} repeatCount="indefinite" />
@@ -221,6 +221,13 @@ export interface ConversationMessage {
221
221
  tool?: string;
222
222
  args?: Record<string, unknown>;
223
223
  result?: unknown;
224
+ /** Attribution on role:"assistant" rows from the global ledger: who answered
225
+ * (stable id + display name + kind), on which model, and what it cost. */
226
+ agent?: string;
227
+ agent_name?: string;
228
+ actor_kind?: string;
229
+ model?: string;
230
+ usage?: ChatUsage;
224
231
  }
225
232
 
226
233
  export interface ConversationDetail {
@@ -408,7 +415,11 @@ export interface ChatStreamEvent {
408
415
  result?: {
409
416
  text?: string;
410
417
  usage?: ChatUsage;
418
+ /** Agent persona that answered (identity.json name / agent slug). */
411
419
  name?: string;
420
+ /** Engine that actually produced the reply — may differ from the configured
421
+ * one when routing fell back mid-turn. */
422
+ model?: string;
412
423
  trace?: ToolTrace[];
413
424
  };
414
425
  }