agent-sh 0.10.3 → 0.12.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.
package/README.md CHANGED
@@ -1,16 +1,15 @@
1
1
  # agent-sh
2
2
 
3
- An agent that lives in a shell not a shell that lives in an agent.
3
+ A real shell with an AI agent one keystroke away.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/agent-sh.svg)](https://www.npmjs.com/package/agent-sh)
6
6
  [![license](https://img.shields.io/npm/l/agent-sh.svg)](https://github.com/guanyilun/agent-sh/blob/main/LICENSE)
7
- [![website](https://img.shields.io/badge/website-agent--sh.dev-blue)](https://agent-sh.dev)
8
7
 
9
8
  ![demo](assets/demo.gif)
10
9
 
11
- Most AI terminal tools get this backwards: the LLM drives the experience and the shell is bolted on as an afterthought. No real PTY, no job control, no vim, fragile `cd` tracking. The agent is the main character and your terminal is a prop.
10
+ I live in my terminal. A lot of the time I'm not coding I'm deploying something, poking at a failing `rsync`, figuring out why `docker build` won't start, fixing a one-liner. And very often I need an AI agent to help. Spinning up a full coding agent for this stuff is overkill, and I got tired of copy-pasting errors into a chat window every time.
12
11
 
13
- agent-sh flips this. It's your shell first full PTY, your rc config, your aliases, everything just works. But type `>` at the start of a line, and you're talking to an agent that has full context of what you've been doing.
12
+ So I built agent-sh. Under the hood it's a normal shell on top of node-pty your rc config, your aliases, vim and tmux all just work. But at the start of any line, type `>` and you're talking to a small agent that already sees your cwd, your last command, and its output. Nothing to set up, no project to explain.
14
13
 
15
14
  ```
16
15
  ~ $ ls -la # real shell command
@@ -20,22 +19,57 @@ agent-sh flips this. It's your shell first — full PTY, your rc config, your al
20
19
  ~ $ > draft a commit message # agent reads your diff and shell history
21
20
  ```
22
21
 
22
+ I still use Claude Code and pi for serious coding work — this doesn't replace them. But for the quick stuff in the terminal, I reach for agent-sh almost every day now. The built-in agent is lightweight and good enough for most of what I throw at it, and when it isn't, bridge extensions let you plug [Claude Code](examples/extensions/claude-code-bridge/) or [pi](examples/extensions/pi-bridge/) in as the backend.
23
+
23
24
  ## Quick Start
24
25
 
26
+ Install the latest from GitHub (recommended — development moves faster than npm releases):
27
+
28
+ ```bash
29
+ npm install -g github:guanyilun/agent-sh
30
+ ```
31
+
32
+ Or the last published npm release:
33
+
25
34
  ```bash
26
35
  npm install -g agent-sh
36
+ ```
37
+
38
+ Pick one of the zero-config paths below — no settings file needed. agent-sh auto-activates a built-in provider when it sees a known key.
39
+
40
+ **Hosted models via OpenRouter** (300+ models, one key):
41
+
42
+ ```bash
43
+ export OPENROUTER_API_KEY=sk-or-...
27
44
  agent-sh
28
45
  ```
29
46
 
30
- Tip: add an alias to your shell config for quick access:
47
+ **OpenAI:**
31
48
 
32
49
  ```bash
33
- alias ash="agent-sh"
50
+ export OPENAI_API_KEY=sk-...
51
+ agent-sh
52
+ ```
53
+
54
+ **Local models** (Ollama, llama.cpp server, LM Studio, vLLM — anything OpenAI-compatible):
55
+
56
+ ```bash
57
+ export OPENAI_API_KEY=ollama # any value; dummy is fine
58
+ export OPENAI_BASE_URL=http://localhost:11434/v1 # point at your server
59
+ agent-sh
34
60
  ```
35
61
 
36
- Set `OPENAI_API_KEY` in your environment (or configure providers in `~/.agent-sh/settings.json`). Works with any OpenAI-compatible API — see the [Usage Guide](docs/usage.md) for provider examples (OpenAI, Ollama, OpenRouter, Together, Groq, LM Studio, vLLM).
62
+ Once running, switch models at any time with `/model <name>` (tab-completes; selection persists across sessions).
63
+
64
+ For richer configuration (multiple providers, extensions), run `agent-sh init` to scaffold `~/.agent-sh/settings.json` with copy-pasteable examples. See the [Usage Guide](docs/usage.md) for the full list of supported providers.
65
+
66
+ Tip — add a shell alias:
67
+
68
+ ```bash
69
+ alias ash="agent-sh"
70
+ ```
37
71
 
38
- Requires Node.js 18+.
72
+ Requires Node.js 18+. Currently supports **bash** and **zsh**; other shells (fish, nushell, etc.) are not yet wired up.
39
73
 
40
74
  ## Key Features
41
75
 
@@ -141,10 +141,33 @@ export class AgentLoop {
141
141
  // here in the ctor so late-registered modes aren't dropped.
142
142
  onCtor("config:add-modes", ({ modes: extra }) => {
143
143
  const providers = new Set(extra.map((m) => m.provider).filter(Boolean));
144
+ const prev = this.modes[this.currentModeIndex];
145
+ // Keep the active mode even if the re-registration drops it (persisted
146
+ // model missing from a refreshed catalog) — otherwise currentModeIndex
147
+ // slips to modes[0] and the next stream() call uses a different model
148
+ // mid-turn.
149
+ const activePreserved = prev &&
150
+ prev.provider &&
151
+ providers.has(prev.provider) &&
152
+ !extra.some((m) => m.model === prev.model && m.provider === prev.provider);
144
153
  this.modes = [
145
- ...this.modes.filter((m) => !m.provider || !providers.has(m.provider)),
154
+ ...this.modes.filter((m) => {
155
+ if (activePreserved && m === prev)
156
+ return true;
157
+ return !m.provider || !providers.has(m.provider);
158
+ }),
146
159
  ...extra,
147
160
  ];
161
+ if (prev) {
162
+ const newIdx = this.modes.findIndex((m) => m.model === prev.model && m.provider === prev.provider);
163
+ if (newIdx !== -1)
164
+ this.currentModeIndex = newIdx;
165
+ }
166
+ if (activePreserved && prev) {
167
+ this.bus.emit("ui:info", {
168
+ message: `${prev.provider}:${prev.model} is not in the refreshed catalog — keeping it active until you /model to another.`,
169
+ });
170
+ }
148
171
  this.bus.emit("config:changed", {});
149
172
  });
150
173
  // Fires before wire() too — agent-backend emits this from
@@ -516,8 +539,9 @@ export class AgentLoop {
516
539
  const target = baseURL ?? provider ?? "provider";
517
540
  return `Could not connect to ${target} (${raw}). Check that the API endpoint is reachable.`;
518
541
  }
519
- // Auth errors
520
- if (status === 401 || raw.toLowerCase().includes("auth")) {
542
+ // Explicit signals only — bare "auth" hit "author" in echoed API params.
543
+ if (status === 401 || status === 403 ||
544
+ /\b(unauthorized|authentication|api[-_ ]?key|invalid[-_ ]?token)\b/i.test(raw)) {
521
545
  return `Authentication failed for ${provider ?? "provider"} (model: ${model}). Check your API key.`;
522
546
  }
523
547
  // Model not found
@@ -619,58 +643,6 @@ export class AgentLoop {
619
643
  "Treat recurring user guidance as standing preferences. " +
620
644
  "If a search returns nothing useful, try: shorter queries, alternate terms, or browse to scan the full timeline. " +
621
645
  "Recall only covers this and recent sessions — for older context, also search the filesystem (grep, glob).", "core");
622
- // ── ask_llm — direct LLM sub-query (from the 24th ash's vision) ──
623
- //
624
- // The ash can ask the LLM a question directly — not as a tool-output
625
- // loop, but as a lightweight sub-query. Use cases: second opinions,
626
- // brainstorming, summarizing complex context, getting a fresh
627
- // perspective without tool overhead. The 24th ash injected this via
628
- // diagnose as a proof-of-concept. The 25th ash made it permanent.
629
- this.toolRegistry.register({
630
- name: "ask_llm",
631
- description: "Send a direct query to the LLM and get a text response. Use for " +
632
- "sub-queries, second opinions, brainstorming, or getting a fresh " +
633
- "perspective on a problem. Much lighter than a full tool loop — " +
634
- "just query in, text out. Optional system prompt sets context.",
635
- input_schema: {
636
- type: "object",
637
- properties: {
638
- query: {
639
- type: "string",
640
- description: "The question or prompt to send to the LLM.",
641
- },
642
- system: {
643
- type: "string",
644
- description: "Optional system prompt to set context for the sub-query.",
645
- },
646
- },
647
- required: ["query"],
648
- },
649
- showOutput: true,
650
- execute: async (args) => {
651
- const messages = [];
652
- if (args.system) {
653
- messages.push({ role: "system", content: args.system });
654
- }
655
- messages.push({ role: "user", content: args.query });
656
- try {
657
- const content = await this.llmClient.complete({
658
- messages,
659
- max_tokens: 2000,
660
- });
661
- return { content: content || "(empty response)", exitCode: 0, isError: false };
662
- }
663
- catch (err) {
664
- const message = err instanceof Error ? err.message : String(err);
665
- return { content: `LLM error: ${message}`, exitCode: 1, isError: true };
666
- }
667
- },
668
- getDisplayInfo: () => ({ kind: "search", icon: "💬" }),
669
- formatCall: (args) => {
670
- const q = args.query?.slice(0, 60);
671
- return `ask_llm: ${q}${args.query?.length > 60 ? "..." : ""}`;
672
- },
673
- });
674
646
  }
675
647
  /**
676
648
  * Register named handlers that extensions can advise.
@@ -678,6 +650,9 @@ export class AgentLoop {
678
650
  */
679
651
  registerHandlers() {
680
652
  const h = this.handlers;
653
+ // Advisable so extensions can inject fallback parsers without
654
+ // subclassing the protocol.
655
+ h.define("tool-protocol:extract-calls", (args) => this.toolProtocol.extractToolCalls(args.text, args.streamedCalls));
681
656
  // System prompt: static identity + behavioral instructions.
682
657
  // Extensions can use registerInstruction() for a managed section,
683
658
  // or advise this handler directly for full control.
@@ -946,7 +921,16 @@ export class AgentLoop {
946
921
  const toolCtx = this.compositor
947
922
  ? { ui: createToolUI(this.bus, this.compositor.surface("agent")) }
948
923
  : undefined;
949
- const result = await tool.execute(args, onChunk, toolCtx);
924
+ // Surface thrown errors as tool results so the agent can self-correct
925
+ // instead of the throw killing the whole turn.
926
+ let result;
927
+ try {
928
+ result = await tool.execute(args, onChunk, toolCtx);
929
+ }
930
+ catch (err) {
931
+ const message = err instanceof Error ? err.message : String(err);
932
+ result = { content: message, exitCode: 1, isError: true };
933
+ }
950
934
  // Invalidate read cache when a file is modified
951
935
  if (tool.modifiesFiles && typeof args.path === "string" && !result.isError) {
952
936
  const absPath = path.resolve(process.cwd(), args.path);
@@ -1065,13 +1049,14 @@ export class AgentLoop {
1065
1049
  // tool_call → tool_result chain some providers require.
1066
1050
  // Stream LLM response with retry
1067
1051
  const result = await this.streamWithRetry(systemPrompt, dynamicContext, signal);
1068
- const { text, toolCalls: streamedToolCalls } = result;
1069
- // Extract tool calls via protocol (API mode uses streamed calls,
1070
- // inline mode parses XML from text)
1071
- const toolCalls = this.toolProtocol.extractToolCalls(text, streamedToolCalls);
1052
+ const { text, toolCalls: streamedToolCalls, extras } = result;
1053
+ const toolCalls = this.handlers.call("tool-protocol:extract-calls", {
1054
+ text,
1055
+ streamedCalls: streamedToolCalls,
1056
+ });
1072
1057
  fullResponseText += text;
1073
1058
  // Record the assistant message via protocol
1074
- this.toolProtocol.recordAssistant(this.conversation, text, toolCalls);
1059
+ this.toolProtocol.recordAssistant(this.conversation, text, toolCalls, extras);
1075
1060
  this.bus.emit("conversation:message-appended", {
1076
1061
  role: "assistant",
1077
1062
  content: text,
@@ -1460,6 +1445,11 @@ export class AgentLoop {
1460
1445
  */
1461
1446
  async streamResponse(systemPrompt, dynamicContext, signal) {
1462
1447
  let text = "";
1448
+ // reasoning_details streams as per-chunk fragments keyed by index;
1449
+ // merge .text per index or the provider rejects the fragmented shape.
1450
+ let reasoningField = null;
1451
+ let reasoning = "";
1452
+ const reasoningDetailsByIndex = new Map();
1463
1453
  const pendingToolCalls = [];
1464
1454
  const rawMessages = [
1465
1455
  { role: "system", content: systemPrompt },
@@ -1481,16 +1471,18 @@ export class AgentLoop {
1481
1471
  }
1482
1472
  // Stream filter strips tool tags from display (inline mode only)
1483
1473
  const streamFilter = this.toolProtocol.createStreamFilter(this.toolRegistry.all().map((t) => t.name));
1484
- const stream = await this.llmClient.stream({
1474
+ const requestParams = {
1485
1475
  messages,
1486
1476
  tools: apiTools,
1487
1477
  model: this.currentModel,
1488
1478
  reasoning_effort: this.shouldSendReasoningEffort() ? this.thinkingLevel : undefined,
1489
- signal,
1490
- });
1479
+ };
1480
+ this.bus.emit("llm:request", requestParams);
1481
+ const stream = await this.llmClient.stream({ ...requestParams, signal });
1491
1482
  for await (const chunk of stream) {
1492
1483
  if (signal.aborted)
1493
1484
  break;
1485
+ this.bus.emit("llm:chunk", { chunk });
1494
1486
  // Token usage (may arrive in a chunk with empty choices)
1495
1487
  if (chunk.usage) {
1496
1488
  const u = chunk.usage;
@@ -1522,11 +1514,29 @@ export class AgentLoop {
1522
1514
  });
1523
1515
  }
1524
1516
  }
1525
- // Reasoning/thinking tokens (non-standard, e.g. DeepSeek)
1526
- if (delta?.reasoning_content) {
1527
- this.bus.emit("agent:thinking-chunk", {
1528
- text: delta.reasoning_content,
1529
- });
1517
+ const d = delta;
1518
+ for (const name of ["reasoning", "reasoning_content"]) {
1519
+ if (typeof d?.[name] === "string" && d[name].length > 0) {
1520
+ reasoning += d[name];
1521
+ reasoningField ??= name;
1522
+ this.bus.emit("agent:thinking-chunk", { text: d[name] });
1523
+ }
1524
+ }
1525
+ if (Array.isArray(d?.reasoning_details)) {
1526
+ for (const x of d.reasoning_details) {
1527
+ const idx = typeof x?.index === "number" ? x.index : reasoningDetailsByIndex.size;
1528
+ const prev = reasoningDetailsByIndex.get(idx);
1529
+ if (!prev) {
1530
+ reasoningDetailsByIndex.set(idx, { ...x });
1531
+ }
1532
+ else {
1533
+ if (typeof x.text === "string")
1534
+ prev.text = (prev.text ?? "") + x.text;
1535
+ for (const [k, v] of Object.entries(x))
1536
+ if (k !== "text" && prev[k] === undefined)
1537
+ prev[k] = v;
1538
+ }
1539
+ }
1530
1540
  }
1531
1541
  // Tool calls (streamed incrementally)
1532
1542
  if (delta?.tool_calls) {
@@ -1574,9 +1584,17 @@ export class AgentLoop {
1574
1584
  tc.argumentsJson = "{}";
1575
1585
  }
1576
1586
  }
1587
+ const extras = {};
1588
+ if (reasoning && reasoningField)
1589
+ extras[reasoningField] = reasoning;
1590
+ if (reasoningDetailsByIndex.size > 0) {
1591
+ extras.reasoning_details = [...reasoningDetailsByIndex.entries()]
1592
+ .sort((a, b) => a[0] - b[0]).map(([, v]) => v);
1593
+ }
1577
1594
  return {
1578
1595
  text,
1579
1596
  toolCalls: pendingToolCalls,
1597
+ extras: Object.keys(extras).length > 0 ? extras : undefined,
1580
1598
  };
1581
1599
  }
1582
1600
  }
@@ -49,12 +49,19 @@ export declare class ConversationState {
49
49
  name: string;
50
50
  arguments: string;
51
51
  };
52
- }[]): void;
52
+ }[], extras?: Record<string, unknown>): void;
53
53
  addToolResult(toolCallId: string, content: string, isError?: boolean): void;
54
54
  /** Add tool results as a user message (for inline tool protocol). */
55
55
  addToolResultInline(content: string): void;
56
56
  addSystemNote(text: string): void;
57
57
  getMessages(): ChatCompletionMessageParam[];
58
+ /**
59
+ * DeepSeek 400s if any assistant in a thinking-mode conversation is
60
+ * missing reasoning_content. Cross-alias here (OpenRouter streams as
61
+ * `reasoning`, DeepSeek input expects `reasoning_content`) and stub
62
+ * gaps (text-only turns, pre-fix messages) with empty string.
63
+ */
64
+ private normalizeReasoningConsistency;
58
65
  /**
59
66
  * Replace the messages array wholesale — the write side for custom
60
67
  * compaction strategies. Invalidates API token baseline since the
@@ -78,21 +78,21 @@ export class ConversationState {
78
78
  this.invalidateMessagesCache();
79
79
  this.eagerNucleateUser(text);
80
80
  }
81
- addAssistantMessage(content, toolCalls) {
81
+ addAssistantMessage(content, toolCalls, extras) {
82
+ // extras is opaque provider payload to echo back (reasoning_content,
83
+ // reasoning_details, etc.). Spread verbatim; shape is the stream
84
+ // parser's concern.
85
+ const base = { role: "assistant", content: content ?? (toolCalls?.length ? null : "") };
82
86
  if (toolCalls?.length) {
83
- this.messages.push({
84
- role: "assistant",
85
- content: content ?? null,
86
- tool_calls: toolCalls.map((tc) => ({
87
- id: tc.id,
88
- type: "function",
89
- function: tc.function,
90
- })),
91
- });
92
- }
93
- else {
94
- this.messages.push({ role: "assistant", content: content ?? "" });
87
+ base.tool_calls = toolCalls.map((tc) => ({
88
+ id: tc.id,
89
+ type: "function",
90
+ function: tc.function,
91
+ }));
95
92
  }
93
+ if (extras)
94
+ Object.assign(base, extras);
95
+ this.messages.push(base);
96
96
  this.invalidateMessagesCache();
97
97
  }
98
98
  addToolResult(toolCallId, content, isError = false) {
@@ -111,7 +111,28 @@ export class ConversationState {
111
111
  this.invalidateMessagesCache();
112
112
  }
113
113
  getMessages() {
114
- return this.messages;
114
+ return this.normalizeReasoningConsistency(this.messages);
115
+ }
116
+ /**
117
+ * DeepSeek 400s if any assistant in a thinking-mode conversation is
118
+ * missing reasoning_content. Cross-alias here (OpenRouter streams as
119
+ * `reasoning`, DeepSeek input expects `reasoning_content`) and stub
120
+ * gaps (text-only turns, pre-fix messages) with empty string.
121
+ */
122
+ normalizeReasoningConsistency(messages) {
123
+ const needsNormalize = messages.some((m) => m.role === "assistant" && (m.reasoning !== undefined ||
124
+ m.reasoning_content !== undefined ||
125
+ m.reasoning_details !== undefined));
126
+ if (!needsNormalize)
127
+ return messages;
128
+ return messages.map((m) => {
129
+ if (m.role !== "assistant")
130
+ return m;
131
+ const a = m;
132
+ if (a.reasoning_content !== undefined)
133
+ return m;
134
+ return { ...m, reasoning_content: a.reasoning ?? "" };
135
+ });
115
136
  }
116
137
  /**
117
138
  * Replace the messages array wholesale — the write side for custom
@@ -36,10 +36,14 @@ export interface SubagentOptions {
36
36
  */
37
37
  dynamicContext?: string;
38
38
  /**
39
- * Per-subagent token budget. When total (prompt+completion) tokens
40
- * exceed this, the subagent terminates gracefully on the next
41
- * iteration. The parent's daily budget still counts these tokens
42
- * via onUsage; this is an additional per-call cap.
39
+ * Per-subagent completion-token budget. When the cumulative
40
+ * completion_tokens across iterations exceeds this, the subagent
41
+ * terminates gracefully on the next iteration. We deliberately don't
42
+ * count prompt tokens: the full history is resent each iteration, so
43
+ * prompt-inclusive counting double-charges context and makes a budget
44
+ * of N exhaust after O(log N) tool calls. Completion tokens measure
45
+ * the work the subagent actually produces. The parent's daily budget
46
+ * still sees real prompt+completion via onUsage.
43
47
  */
44
48
  budgetTokens?: number;
45
49
  /**
@@ -28,13 +28,13 @@ export async function runSubagent(opts) {
28
28
  break;
29
29
  }
30
30
  // Stream LLM response
31
- const { text, toolCalls, assistantContent, assistantToolCalls, usage } = await streamOnce(llmClient, systemPrompt, conversation, apiTools, model, signal, dynamicContext);
31
+ const { text, toolCalls, assistantContent, assistantToolCalls, extras, usage } = await streamOnce(llmClient, systemPrompt, conversation, apiTools, model, signal, dynamicContext);
32
32
  if (usage) {
33
- tokensConsumed += usage.total_tokens || 0;
33
+ tokensConsumed += usage.completion_tokens || 0;
34
34
  onUsage?.(usage);
35
35
  }
36
36
  fullResponseText += text;
37
- conversation.addAssistantMessage(assistantContent, assistantToolCalls);
37
+ conversation.addAssistantMessage(assistantContent, assistantToolCalls, extras);
38
38
  // No tool calls → done
39
39
  if (toolCalls.length === 0)
40
40
  break;
@@ -86,7 +86,7 @@ export async function runSubagent(opts) {
86
86
  }
87
87
  }
88
88
  if (budgetExhausted) {
89
- const note = `\n\n[Subagent terminated: token budget (${budgetTokens}) exhausted after ${tokensConsumed} tokens. Returning partial progress.]`;
89
+ const note = `\n\n[Subagent terminated: completion-token budget (${budgetTokens}) exhausted after ${tokensConsumed} completion tokens. Returning partial progress.]`;
90
90
  return fullResponseText + note;
91
91
  }
92
92
  return fullResponseText;
@@ -94,6 +94,9 @@ export async function runSubagent(opts) {
94
94
  /** Stream a single LLM response. */
95
95
  async function streamOnce(llmClient, systemPrompt, conversation, apiTools, model, signal, dynamicContext) {
96
96
  let text = "";
97
+ let reasoning = "";
98
+ let reasoningField = null;
99
+ const reasoningDetailsByIndex = new Map();
97
100
  const pendingToolCalls = [];
98
101
  let usage = null;
99
102
  const messages = [
@@ -127,6 +130,29 @@ async function streamOnce(llmClient, systemPrompt, conversation, apiTools, model
127
130
  if (delta?.content) {
128
131
  text += delta.content;
129
132
  }
133
+ const d = delta;
134
+ for (const name of ["reasoning", "reasoning_content"]) {
135
+ if (typeof d?.[name] === "string" && d[name].length > 0) {
136
+ reasoning += d[name];
137
+ reasoningField ??= name;
138
+ }
139
+ }
140
+ if (Array.isArray(d?.reasoning_details)) {
141
+ for (const x of d.reasoning_details) {
142
+ const idx = typeof x?.index === "number" ? x.index : reasoningDetailsByIndex.size;
143
+ const prev = reasoningDetailsByIndex.get(idx);
144
+ if (!prev) {
145
+ reasoningDetailsByIndex.set(idx, { ...x });
146
+ }
147
+ else {
148
+ if (typeof x.text === "string")
149
+ prev.text = (prev.text ?? "") + x.text;
150
+ for (const [k, v] of Object.entries(x))
151
+ if (k !== "text" && prev[k] === undefined)
152
+ prev[k] = v;
153
+ }
154
+ }
155
+ }
130
156
  if (delta?.tool_calls) {
131
157
  for (const tc of delta.tool_calls) {
132
158
  const idx = tc.index;
@@ -157,5 +183,19 @@ async function streamOnce(llmClient, systemPrompt, conversation, apiTools, model
157
183
  const assistantToolCalls = pendingToolCalls.length
158
184
  ? pendingToolCalls.map(tc => ({ id: tc.id, function: { name: tc.name, arguments: tc.argumentsJson } }))
159
185
  : undefined;
160
- return { text, toolCalls: pendingToolCalls, assistantContent: text || null, assistantToolCalls, usage };
186
+ const extras = {};
187
+ if (reasoning && reasoningField)
188
+ extras[reasoningField] = reasoning;
189
+ if (reasoningDetailsByIndex.size > 0) {
190
+ extras.reasoning_details = [...reasoningDetailsByIndex.entries()]
191
+ .sort((a, b) => a[0] - b[0]).map(([, v]) => v);
192
+ }
193
+ return {
194
+ text,
195
+ toolCalls: pendingToolCalls,
196
+ assistantContent: text || null,
197
+ assistantToolCalls,
198
+ extras: Object.keys(extras).length > 0 ? extras : undefined,
199
+ usage,
200
+ };
161
201
  }
@@ -39,7 +39,7 @@ export interface ToolProtocol {
39
39
  /** Rewrite a tool call before execution (e.g., unwrap meta-tool). */
40
40
  rewriteToolCall(tc: PendingToolCall): PendingToolCall;
41
41
  /** Record the assistant turn in conversation state. */
42
- recordAssistant(conv: ConversationState, text: string, toolCalls: PendingToolCall[]): void;
42
+ recordAssistant(conv: ConversationState, text: string, toolCalls: PendingToolCall[], extras?: Record<string, unknown>): void;
43
43
  /** Record all tool results for a batch as conversation messages. */
44
44
  recordResults(conv: ConversationState, results: ToolResult[]): void;
45
45
  /** Create a stream filter for stripping tool calls from display. null = pass-through. */
@@ -57,7 +57,7 @@ export declare class ApiToolProtocol implements ToolProtocol {
57
57
  getToolPrompt(): string;
58
58
  extractToolCalls(_text: string, streamedCalls: PendingToolCall[]): PendingToolCall[];
59
59
  rewriteToolCall(tc: PendingToolCall): PendingToolCall;
60
- recordAssistant(conv: ConversationState, text: string, toolCalls: PendingToolCall[]): void;
60
+ recordAssistant(conv: ConversationState, text: string, toolCalls: PendingToolCall[], extras?: Record<string, unknown>): void;
61
61
  recordResults(conv: ConversationState, results: ToolResult[]): void;
62
62
  createStreamFilter(): null;
63
63
  }
@@ -68,7 +68,7 @@ export declare class InlineToolProtocol implements ToolProtocol {
68
68
  getToolPrompt(tools: ToolDefinition[]): string;
69
69
  rewriteToolCall(tc: PendingToolCall): PendingToolCall;
70
70
  extractToolCalls(text: string, _streamedCalls: PendingToolCall[]): PendingToolCall[];
71
- recordAssistant(conv: ConversationState, text: string, _toolCalls: PendingToolCall[]): void;
71
+ recordAssistant(conv: ConversationState, text: string, _toolCalls: PendingToolCall[], extras?: Record<string, unknown>): void;
72
72
  recordResults(conv: ConversationState, results: ToolResult[]): void;
73
73
  createStreamFilter(_toolNames: string[]): StreamFilter;
74
74
  }
@@ -82,7 +82,7 @@ export declare class DeferredToolProtocol implements ToolProtocol {
82
82
  getToolPrompt(): string;
83
83
  extractToolCalls(_text: string, streamedCalls: PendingToolCall[]): PendingToolCall[];
84
84
  rewriteToolCall(tc: PendingToolCall): PendingToolCall;
85
- recordAssistant(conv: ConversationState, text: string, toolCalls: PendingToolCall[]): void;
85
+ recordAssistant(conv: ConversationState, text: string, toolCalls: PendingToolCall[], extras?: Record<string, unknown>): void;
86
86
  recordResults(conv: ConversationState, results: ToolResult[]): void;
87
87
  createStreamFilter(): null;
88
88
  }
@@ -97,7 +97,7 @@ export declare class DeferredLookupProtocol implements ToolProtocol {
97
97
  getToolPrompt(): string;
98
98
  extractToolCalls(_text: string, streamedCalls: PendingToolCall[]): PendingToolCall[];
99
99
  rewriteToolCall(tc: PendingToolCall): PendingToolCall;
100
- recordAssistant(conv: ConversationState, text: string, toolCalls: PendingToolCall[]): void;
100
+ recordAssistant(conv: ConversationState, text: string, toolCalls: PendingToolCall[], extras?: Record<string, unknown>): void;
101
101
  recordResults(conv: ConversationState, results: ToolResult[]): void;
102
102
  createStreamFilter(): null;
103
103
  getProtocolTools(): ToolDefinition[];
@@ -22,14 +22,14 @@ export class ApiToolProtocol {
22
22
  rewriteToolCall(tc) {
23
23
  return tc;
24
24
  }
25
- recordAssistant(conv, text, toolCalls) {
25
+ recordAssistant(conv, text, toolCalls, extras) {
26
26
  const calls = toolCalls.length
27
27
  ? toolCalls.map((tc) => ({
28
28
  id: tc.id,
29
29
  function: { name: tc.name, arguments: tc.argumentsJson },
30
30
  }))
31
31
  : undefined;
32
- conv.addAssistantMessage(text || null, calls);
32
+ conv.addAssistantMessage(text || null, calls, extras);
33
33
  }
34
34
  recordResults(conv, results) {
35
35
  for (const r of results) {
@@ -97,8 +97,8 @@ export class InlineToolProtocol {
97
97
  }
98
98
  return calls;
99
99
  }
100
- recordAssistant(conv, text, _toolCalls) {
101
- conv.addAssistantMessage(text || null);
100
+ recordAssistant(conv, text, _toolCalls, extras) {
101
+ conv.addAssistantMessage(text || null, undefined, extras);
102
102
  }
103
103
  recordResults(conv, results) {
104
104
  if (results.length === 0)
@@ -351,14 +351,14 @@ export class DeferredToolProtocol {
351
351
  return tc; // Let it fail naturally downstream
352
352
  }
353
353
  }
354
- recordAssistant(conv, text, toolCalls) {
354
+ recordAssistant(conv, text, toolCalls, extras) {
355
355
  const calls = toolCalls.length
356
356
  ? toolCalls.map((tc) => ({
357
357
  id: tc.id,
358
358
  function: { name: tc.name, arguments: tc.argumentsJson },
359
359
  }))
360
360
  : undefined;
361
- conv.addAssistantMessage(text || null, calls);
361
+ conv.addAssistantMessage(text || null, calls, extras);
362
362
  }
363
363
  recordResults(conv, results) {
364
364
  for (const r of results) {
@@ -444,14 +444,14 @@ export class DeferredLookupProtocol {
444
444
  rewriteToolCall(tc) {
445
445
  return tc; // no dispatching needed — load_tool is a real registered tool
446
446
  }
447
- recordAssistant(conv, text, toolCalls) {
447
+ recordAssistant(conv, text, toolCalls, extras) {
448
448
  const calls = toolCalls.length
449
449
  ? toolCalls.map((tc) => ({
450
450
  id: tc.id,
451
451
  function: { name: tc.name, arguments: tc.argumentsJson },
452
452
  }))
453
453
  : undefined;
454
- conv.addAssistantMessage(text || null, calls);
454
+ conv.addAssistantMessage(text || null, calls, extras);
455
455
  }
456
456
  recordResults(conv, results) {
457
457
  for (const r of results) {
package/dist/core.d.ts CHANGED
@@ -22,7 +22,7 @@ import type { AgentShellConfig, ExtensionContext } from "./types.js";
22
22
  import { HandlerRegistry } from "./utils/handler-registry.js";
23
23
  export { EventBus } from "./event-bus.js";
24
24
  export type { ShellEvents } from "./event-bus.js";
25
- export type { AgentShellConfig, ExtensionContext } from "./types.js";
25
+ export type { AgentShellConfig, ExtensionContext, LlmInterface, LlmMessage, LlmSession } from "./types.js";
26
26
  export { palette, setPalette, resetPalette } from "./utils/palette.js";
27
27
  export type { ColorPalette } from "./utils/palette.js";
28
28
  export type { AgentBackend, ToolDefinition } from "./agent/types.js";
package/dist/core.js CHANGED
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { EventBus } from "./event-bus.js";
20
20
  import { ContextManager } from "./context-manager.js";
21
+ import { createLlmFacade } from "./utils/llm-facade.js";
21
22
  import { setPalette } from "./utils/palette.js";
22
23
  import * as streamTransform from "./utils/stream-transform.js";
23
24
  import * as settingsMod from "./settings.js";
@@ -161,6 +162,7 @@ export function createCore(config) {
161
162
  bus,
162
163
  contextManager,
163
164
  instanceId,
165
+ llm: createLlmFacade(handlers),
164
166
  quit: opts.quit,
165
167
  setPalette,
166
168
  createBlockTransform: (o) => streamTransform.createBlockTransform(bus, o),