orbitx 0.2.6 → 0.2.8

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 (283) hide show
  1. package/dist/core/agent-providers.d.ts +15 -10
  2. package/dist/core/agent-providers.d.ts.map +1 -1
  3. package/dist/core/agent-providers.js +65 -15
  4. package/dist/core/agent-providers.js.map +1 -1
  5. package/dist/core/ai-provider.d.ts +52 -2
  6. package/dist/core/ai-provider.d.ts.map +1 -1
  7. package/dist/core/ai-provider.js.map +1 -1
  8. package/dist/core/anthropic-provider.d.ts +5 -0
  9. package/dist/core/anthropic-provider.d.ts.map +1 -1
  10. package/dist/core/anthropic-provider.js +100 -17
  11. package/dist/core/anthropic-provider.js.map +1 -1
  12. package/dist/core/base-agent.d.ts +19 -41
  13. package/dist/core/base-agent.d.ts.map +1 -1
  14. package/dist/core/base-agent.js +112 -136
  15. package/dist/core/base-agent.js.map +1 -1
  16. package/dist/core/deepseek-provider.d.ts +3 -0
  17. package/dist/core/deepseek-provider.d.ts.map +1 -1
  18. package/dist/core/deepseek-provider.js +59 -12
  19. package/dist/core/deepseek-provider.js.map +1 -1
  20. package/dist/core/mcp-client.d.ts +13 -4
  21. package/dist/core/mcp-client.d.ts.map +1 -1
  22. package/dist/core/mcp-client.js +40 -7
  23. package/dist/core/mcp-client.js.map +1 -1
  24. package/dist/core/mcp-execution-policy.d.ts +14 -0
  25. package/dist/core/mcp-execution-policy.d.ts.map +1 -0
  26. package/dist/core/mcp-execution-policy.js +9 -0
  27. package/dist/core/mcp-execution-policy.js.map +1 -0
  28. package/dist/core/mcp-filter.d.ts +6 -2
  29. package/dist/core/mcp-filter.d.ts.map +1 -1
  30. package/dist/core/mcp-filter.js +4 -2
  31. package/dist/core/mcp-filter.js.map +1 -1
  32. package/dist/core/mcp-server.d.ts +5 -1
  33. package/dist/core/mcp-server.d.ts.map +1 -1
  34. package/dist/core/mcp-server.js +31 -1
  35. package/dist/core/mcp-server.js.map +1 -1
  36. package/dist/core/mcp.d.ts +9 -23
  37. package/dist/core/mcp.d.ts.map +1 -1
  38. package/dist/core/mcp.js +1 -24
  39. package/dist/core/mcp.js.map +1 -1
  40. package/dist/core/ollama-provider.d.ts +3 -0
  41. package/dist/core/ollama-provider.d.ts.map +1 -1
  42. package/dist/core/ollama-provider.js +31 -7
  43. package/dist/core/ollama-provider.js.map +1 -1
  44. package/dist/core/openai-provider.d.ts +3 -0
  45. package/dist/core/openai-provider.d.ts.map +1 -1
  46. package/dist/core/openai-provider.js +51 -8
  47. package/dist/core/openai-provider.js.map +1 -1
  48. package/dist/core/skill.d.ts +2 -2
  49. package/dist/core/skill.d.ts.map +1 -1
  50. package/dist/index.d.ts +16 -21
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +11 -11
  53. package/dist/index.js.map +1 -1
  54. package/dist/templates/simple.d.ts +8 -12
  55. package/dist/templates/simple.d.ts.map +1 -1
  56. package/dist/templates/simple.js +6 -10
  57. package/dist/templates/simple.js.map +1 -1
  58. package/dist/tests/core/mcp-execution-policy.test.d.ts +2 -0
  59. package/dist/tests/core/mcp-execution-policy.test.d.ts.map +1 -0
  60. package/dist/tests/core/mcp-execution-policy.test.js +77 -0
  61. package/dist/tests/core/mcp-execution-policy.test.js.map +1 -0
  62. package/dist/tests/core/mcp-output-filter.test.d.ts +2 -0
  63. package/dist/tests/core/mcp-output-filter.test.d.ts.map +1 -0
  64. package/dist/tests/core/mcp-output-filter.test.js +43 -0
  65. package/dist/tests/core/mcp-output-filter.test.js.map +1 -0
  66. package/dist/tests/index.d.ts +2 -0
  67. package/dist/tests/index.d.ts.map +1 -1
  68. package/dist/tests/index.js +2 -0
  69. package/dist/tests/index.js.map +1 -1
  70. package/dist/tests/tools/agent/agent-tools.test.js +20 -17
  71. package/dist/tests/tools/agent/agent-tools.test.js.map +1 -1
  72. package/dist/tests/tools/question-answer/question-answer.test.js +1 -11
  73. package/dist/tests/tools/question-answer/question-answer.test.js.map +1 -1
  74. package/dist/tests/tools/utils/read-image.test.js +29 -17
  75. package/dist/tests/tools/utils/read-image.test.js.map +1 -1
  76. package/dist/tools/agent/hire.d.ts +1 -2
  77. package/dist/tools/agent/hire.d.ts.map +1 -1
  78. package/dist/tools/agent/hire.js +1 -4
  79. package/dist/tools/agent/hire.js.map +1 -1
  80. package/dist/tools/agent/index.d.ts +1 -3
  81. package/dist/tools/agent/index.d.ts.map +1 -1
  82. package/dist/tools/agent/index.js +0 -1
  83. package/dist/tools/agent/index.js.map +1 -1
  84. package/dist/tools/agent/list.d.ts +1 -1
  85. package/dist/tools/agent/list.d.ts.map +1 -1
  86. package/dist/tools/agent/prompt.d.ts +1 -2
  87. package/dist/tools/agent/prompt.d.ts.map +1 -1
  88. package/dist/tools/agent/prompt.js +11 -7
  89. package/dist/tools/agent/prompt.js.map +1 -1
  90. package/dist/tools/agent/report.d.ts +1 -1
  91. package/dist/tools/agent/report.d.ts.map +1 -1
  92. package/dist/tools/bash/index.d.ts +1 -3
  93. package/dist/tools/bash/index.d.ts.map +1 -1
  94. package/dist/tools/bash/index.js +0 -1
  95. package/dist/tools/bash/index.js.map +1 -1
  96. package/dist/tools/bash/list.d.ts +1 -1
  97. package/dist/tools/bash/list.d.ts.map +1 -1
  98. package/dist/tools/bash/list.js +1 -1
  99. package/dist/tools/bash/list.js.map +1 -1
  100. package/dist/tools/bash/logs.d.ts +1 -1
  101. package/dist/tools/bash/logs.d.ts.map +1 -1
  102. package/dist/tools/bash/logs.js +1 -1
  103. package/dist/tools/bash/logs.js.map +1 -1
  104. package/dist/tools/bash/run.d.ts +1 -2
  105. package/dist/tools/bash/run.d.ts.map +1 -1
  106. package/dist/tools/bash/run.js +1 -7
  107. package/dist/tools/bash/run.js.map +1 -1
  108. package/dist/tools/bash/terminate.d.ts +1 -2
  109. package/dist/tools/bash/terminate.d.ts.map +1 -1
  110. package/dist/tools/bash/terminate.js +1 -4
  111. package/dist/tools/bash/terminate.js.map +1 -1
  112. package/dist/tools/bash/wait.d.ts +1 -2
  113. package/dist/tools/bash/wait.d.ts.map +1 -1
  114. package/dist/tools/bash/wait.js +1 -7
  115. package/dist/tools/bash/wait.js.map +1 -1
  116. package/dist/tools/bash/write-input.d.ts +1 -2
  117. package/dist/tools/bash/write-input.d.ts.map +1 -1
  118. package/dist/tools/bash/write-input.js +1 -4
  119. package/dist/tools/bash/write-input.js.map +1 -1
  120. package/dist/tools/browser/click.d.ts +1 -2
  121. package/dist/tools/browser/click.d.ts.map +1 -1
  122. package/dist/tools/browser/click.js +1 -4
  123. package/dist/tools/browser/click.js.map +1 -1
  124. package/dist/tools/browser/console.d.ts +1 -1
  125. package/dist/tools/browser/console.d.ts.map +1 -1
  126. package/dist/tools/browser/create-session.d.ts +1 -2
  127. package/dist/tools/browser/create-session.d.ts.map +1 -1
  128. package/dist/tools/browser/create-session.js +1 -4
  129. package/dist/tools/browser/create-session.js.map +1 -1
  130. package/dist/tools/browser/fill.d.ts +1 -2
  131. package/dist/tools/browser/fill.d.ts.map +1 -1
  132. package/dist/tools/browser/fill.js +1 -4
  133. package/dist/tools/browser/fill.js.map +1 -1
  134. package/dist/tools/browser/get-sessions.d.ts +1 -1
  135. package/dist/tools/browser/get-sessions.d.ts.map +1 -1
  136. package/dist/tools/browser/index.d.ts +1 -3
  137. package/dist/tools/browser/index.d.ts.map +1 -1
  138. package/dist/tools/browser/index.js +0 -1
  139. package/dist/tools/browser/index.js.map +1 -1
  140. package/dist/tools/browser/inject.d.ts +1 -2
  141. package/dist/tools/browser/inject.d.ts.map +1 -1
  142. package/dist/tools/browser/inject.js +1 -4
  143. package/dist/tools/browser/inject.js.map +1 -1
  144. package/dist/tools/browser/navigate.d.ts +1 -2
  145. package/dist/tools/browser/navigate.d.ts.map +1 -1
  146. package/dist/tools/browser/navigate.js +1 -4
  147. package/dist/tools/browser/navigate.js.map +1 -1
  148. package/dist/tools/browser/network-status.d.ts +1 -1
  149. package/dist/tools/browser/network-status.d.ts.map +1 -1
  150. package/dist/tools/browser/network.d.ts +1 -1
  151. package/dist/tools/browser/network.d.ts.map +1 -1
  152. package/dist/tools/browser/read.d.ts +1 -2
  153. package/dist/tools/browser/read.d.ts.map +1 -1
  154. package/dist/tools/browser/read.js +1 -4
  155. package/dist/tools/browser/read.js.map +1 -1
  156. package/dist/tools/browser/remove-session.d.ts +1 -2
  157. package/dist/tools/browser/remove-session.d.ts.map +1 -1
  158. package/dist/tools/browser/remove-session.js +1 -4
  159. package/dist/tools/browser/remove-session.js.map +1 -1
  160. package/dist/tools/browser/screenshot.d.ts +1 -2
  161. package/dist/tools/browser/screenshot.d.ts.map +1 -1
  162. package/dist/tools/browser/screenshot.js +16 -15
  163. package/dist/tools/browser/screenshot.js.map +1 -1
  164. package/dist/tools/browser/scroll-info.d.ts +1 -1
  165. package/dist/tools/browser/scroll-info.d.ts.map +1 -1
  166. package/dist/tools/browser/scroll.d.ts +1 -2
  167. package/dist/tools/browser/scroll.d.ts.map +1 -1
  168. package/dist/tools/browser/scroll.js +1 -4
  169. package/dist/tools/browser/scroll.js.map +1 -1
  170. package/dist/tools/browser/submit-form.d.ts +1 -2
  171. package/dist/tools/browser/submit-form.d.ts.map +1 -1
  172. package/dist/tools/browser/submit-form.js +1 -4
  173. package/dist/tools/browser/submit-form.js.map +1 -1
  174. package/dist/tools/fs/create-dir.d.ts +1 -2
  175. package/dist/tools/fs/create-dir.d.ts.map +1 -1
  176. package/dist/tools/fs/create-dir.js +1 -4
  177. package/dist/tools/fs/create-dir.js.map +1 -1
  178. package/dist/tools/fs/delete.d.ts +1 -2
  179. package/dist/tools/fs/delete.d.ts.map +1 -1
  180. package/dist/tools/fs/delete.js +1 -4
  181. package/dist/tools/fs/delete.js.map +1 -1
  182. package/dist/tools/fs/edit-file.d.ts +1 -2
  183. package/dist/tools/fs/edit-file.d.ts.map +1 -1
  184. package/dist/tools/fs/edit-file.js +1 -4
  185. package/dist/tools/fs/edit-file.js.map +1 -1
  186. package/dist/tools/fs/index.d.ts +1 -3
  187. package/dist/tools/fs/index.d.ts.map +1 -1
  188. package/dist/tools/fs/index.js +0 -1
  189. package/dist/tools/fs/index.js.map +1 -1
  190. package/dist/tools/fs/list-dir.d.ts +1 -2
  191. package/dist/tools/fs/list-dir.d.ts.map +1 -1
  192. package/dist/tools/fs/list-dir.js +1 -4
  193. package/dist/tools/fs/list-dir.js.map +1 -1
  194. package/dist/tools/fs/move.d.ts +1 -2
  195. package/dist/tools/fs/move.d.ts.map +1 -1
  196. package/dist/tools/fs/move.js +1 -4
  197. package/dist/tools/fs/move.js.map +1 -1
  198. package/dist/tools/fs/read-file.d.ts +1 -2
  199. package/dist/tools/fs/read-file.d.ts.map +1 -1
  200. package/dist/tools/fs/read-file.js +1 -4
  201. package/dist/tools/fs/read-file.js.map +1 -1
  202. package/dist/tools/fs/stat.d.ts +1 -2
  203. package/dist/tools/fs/stat.d.ts.map +1 -1
  204. package/dist/tools/fs/stat.js +1 -4
  205. package/dist/tools/fs/stat.js.map +1 -1
  206. package/dist/tools/fs/write-file.d.ts +1 -2
  207. package/dist/tools/fs/write-file.d.ts.map +1 -1
  208. package/dist/tools/fs/write-file.js +1 -4
  209. package/dist/tools/fs/write-file.js.map +1 -1
  210. package/dist/tools/present/add.d.ts +1 -2
  211. package/dist/tools/present/add.d.ts.map +1 -1
  212. package/dist/tools/present/add.js +1 -4
  213. package/dist/tools/present/add.js.map +1 -1
  214. package/dist/tools/present/clear.d.ts +1 -2
  215. package/dist/tools/present/clear.d.ts.map +1 -1
  216. package/dist/tools/present/clear.js +1 -4
  217. package/dist/tools/present/clear.js.map +1 -1
  218. package/dist/tools/present/get-list.d.ts +1 -2
  219. package/dist/tools/present/get-list.d.ts.map +1 -1
  220. package/dist/tools/present/get-list.js +0 -2
  221. package/dist/tools/present/get-list.js.map +1 -1
  222. package/dist/tools/present/index.d.ts +1 -3
  223. package/dist/tools/present/index.d.ts.map +1 -1
  224. package/dist/tools/present/index.js +0 -1
  225. package/dist/tools/present/index.js.map +1 -1
  226. package/dist/tools/question-answer/index.d.ts +1 -3
  227. package/dist/tools/question-answer/index.d.ts.map +1 -1
  228. package/dist/tools/question-answer/index.js +0 -1
  229. package/dist/tools/question-answer/index.js.map +1 -1
  230. package/dist/tools/question-answer/question-answer.d.ts +1 -2
  231. package/dist/tools/question-answer/question-answer.d.ts.map +1 -1
  232. package/dist/tools/question-answer/question-answer.js +1 -4
  233. package/dist/tools/question-answer/question-answer.js.map +1 -1
  234. package/dist/tools/todo/check-task.d.ts +1 -2
  235. package/dist/tools/todo/check-task.d.ts.map +1 -1
  236. package/dist/tools/todo/check-task.js +1 -4
  237. package/dist/tools/todo/check-task.js.map +1 -1
  238. package/dist/tools/todo/create-list.d.ts +1 -2
  239. package/dist/tools/todo/create-list.d.ts.map +1 -1
  240. package/dist/tools/todo/create-list.js +1 -4
  241. package/dist/tools/todo/create-list.js.map +1 -1
  242. package/dist/tools/todo/create-task.d.ts +1 -2
  243. package/dist/tools/todo/create-task.d.ts.map +1 -1
  244. package/dist/tools/todo/create-task.js +1 -4
  245. package/dist/tools/todo/create-task.js.map +1 -1
  246. package/dist/tools/todo/get-list.d.ts +1 -1
  247. package/dist/tools/todo/get-list.d.ts.map +1 -1
  248. package/dist/tools/todo/get-list.js +1 -1
  249. package/dist/tools/todo/get-list.js.map +1 -1
  250. package/dist/tools/todo/get-lists.d.ts +1 -1
  251. package/dist/tools/todo/get-lists.d.ts.map +1 -1
  252. package/dist/tools/todo/get-lists.js +1 -1
  253. package/dist/tools/todo/get-lists.js.map +1 -1
  254. package/dist/tools/todo/index.d.ts +1 -3
  255. package/dist/tools/todo/index.d.ts.map +1 -1
  256. package/dist/tools/todo/index.js +0 -1
  257. package/dist/tools/todo/index.js.map +1 -1
  258. package/dist/tools/todo/remove-list.d.ts +1 -2
  259. package/dist/tools/todo/remove-list.d.ts.map +1 -1
  260. package/dist/tools/todo/remove-list.js +1 -4
  261. package/dist/tools/todo/remove-list.js.map +1 -1
  262. package/dist/tools/todo/remove-task.d.ts +1 -2
  263. package/dist/tools/todo/remove-task.d.ts.map +1 -1
  264. package/dist/tools/todo/remove-task.js +1 -4
  265. package/dist/tools/todo/remove-task.js.map +1 -1
  266. package/dist/tools/utils/delay.d.ts +1 -1
  267. package/dist/tools/utils/delay.d.ts.map +1 -1
  268. package/dist/tools/utils/get-current-time.d.ts +1 -1
  269. package/dist/tools/utils/get-current-time.d.ts.map +1 -1
  270. package/dist/tools/utils/index.d.ts +1 -1
  271. package/dist/tools/utils/index.d.ts.map +1 -1
  272. package/dist/tools/utils/read-image.d.ts +12 -7
  273. package/dist/tools/utils/read-image.d.ts.map +1 -1
  274. package/dist/tools/utils/read-image.js +27 -17
  275. package/dist/tools/utils/read-image.js.map +1 -1
  276. package/docs/agents.md +50 -25
  277. package/docs/api-reference.md +14 -14
  278. package/docs/getting-started.md +2 -2
  279. package/docs/mcp-architecture.md +52 -6
  280. package/docs/providers.md +114 -15
  281. package/docs/streaming.md +19 -2
  282. package/docs/tools.md +43 -10
  283. package/package.json +1 -1
package/docs/agents.md CHANGED
@@ -11,26 +11,36 @@
11
11
  ```ts
12
12
  new BaseAgent({
13
13
  instruction: string;
14
+ safetyPolicies?: string; // default: "" — see "Guardrail system prompt" below
14
15
  allowedTools: MCPTool<any>[];
15
16
  aiProvider: AIProvider | AgentProviderEntry[]; // see Providers doc
16
17
  mcpClient: MCPClient;
17
18
  skills?: Skill[];
18
19
  maxMemorizeToken?: number; // default: derived from the main provider's contextWindow * safeUsageRatio
19
20
  initData?: {
20
- memory: string;
21
- messagesFull: Message[];
22
- fullInputMissTokens: number;
23
- fullInputHitTokens: number;
24
- fullOutputTokens: number;
25
- messagesCompact: Message[];
26
- currentInputMissTokens: number;
27
- currentInputHitTokens: number;
28
- currentOutputTokens: number;
21
+ compactMemory: string;
22
+ retiredMessages: Message[]; // complete, ever-appended history of retired messages — each Message carries its own usage/timestamp
23
+ messagesCompact: Message[]; // the working context currently replayed to the provider every turn
24
+ };
25
+ features?: {
26
+ executeProviderFromMCPTool?: boolean; // default: false — see "executeProvider feature flag" below
29
27
  };
30
28
  });
31
29
  ```
32
30
 
33
- `SimpleAgent`'s constructor is the same shape minus `mcpClient`/`allowedTools` (it builds those from `tools`), plus `tools?: MCPTool<any>[]` and `maxMemorizeToken` defaulting to `16000` instead of being derived.
31
+ ### Guardrail system prompt
32
+
33
+ `safetyPolicies` is a separate `role: "system"` message, sent as its own leading entry ahead of the regular system prompt (see [Instructions, skills, and memory](#instructions-skills-and-memory-in-the-system-prompt)) on every main-provider `chat()` call in the run loop. Keep house rules/guardrails here rather than folding them into `instruction`, since it's a separate, cache-stable prefix ahead of the rest of the system prompt.
34
+
35
+ It is **not** prepended on an `executeProvider` call (see below) — stuffing a policy paragraph ahead of a narrow, single-purpose call (e.g. "describe this image") measurably degrades that call's output, and not every registered provider role even accepts a `system` message the way the main LLM does. A pre-dispatch content-safety check is the intended replacement there; not implemented yet.
36
+
37
+ ### `executeProvider` feature flag
38
+
39
+ A tool's `execute()` can reach a registered `AIProvider` directly via `mcp.executeProvider(toolCallId, type, input)` (see [Tools](./tools.md#mcpexecuteprovider--calling-a-provider-from-inside-a-tool)) — a bigger trust surface than a tool that only returns data to the model, since it lets a tool spend real provider calls (and, per the point above, without a guardrail system prompt ahead of them). It's off by default: `BaseAgent#executeProvider` throws immediately unless `features.executeProviderFromMCPTool` was set `true` on construction. This gates every role uniformly (`image-describer` included) — enable it once you're ready to trust the tools you've allowed with direct provider access.
40
+
41
+ `SimpleAgent`'s constructor is the same shape minus `mcpClient`/`allowedTools` (it builds those from `tools`), plus `tools?: MCPTool<any>[]` and `maxMemorizeToken` defaulting to `16000` instead of being derived. `safetyPolicies` and `features` pass straight through to the underlying `BaseAgent`.
42
+
43
+ Token accounting no longer lives in `initData` as separate counters — every `Message` carries its own `usage` (see [Token accounting](#token-accounting)), so `retiredMessages`/`messagesCompact` alone are enough to resume with full token history intact.
34
44
 
35
45
  ## The run loop
36
46
 
@@ -60,6 +70,8 @@ Practical implication: don't treat an early queued call's resolved `run()` promi
60
70
 
61
71
  `immediateStop()` requests the same stop but returns right away without waiting for confirmation, **and** aborts the in-flight `chat()` call to the main provider immediately — the underlying HTTP request is cancelled rather than left to run to completion, so no further output tokens get generated (and billed) past that point. Input tokens, and any output already generated before the abort landed, are already committed — cancelling can't retroactively make those free. This is the one to reach for when a call has to be killed regardless of how far through it is (e.g. a credit balance that just went negative). A tool dispatch already in progress isn't aborted (tools have no generic cancellation hook) — the loop stops as soon as that call returns. Abort support is real for Anthropic, OpenAI, and DeepSeek (both streaming and non-streaming); for Ollama it only takes effect on streaming calls — see the note in `ollama-provider.ts`.
62
72
 
73
+ If you're deciding *when* to call `immediateStop()` from outside — e.g. against an external credit/cost system — watch `usage` on the `StreamCallback` chunks (see [Streaming](./streaming.md)) rather than waiting for `run()` to resolve: it's the same callback passed into `run()`, forwarded straight through to the provider, so external code sees the same numbers `BaseAgent` itself uses to build `Message.usage`. Note this is only genuinely live for Anthropic — the other three providers only report usage once a turn is already complete, so for them there's no earlier signal to react to, cancellation-timing-wise, than the final chunk.
74
+
63
75
  For a **streaming** call, an abort doesn't throw the turn away: `chat()` resolves normally with whatever text, thinking, and tool calls were already accumulated before the cutoff — the same as if the model had naturally stopped there — so anything already sent to `streamCallback` still ends up recorded in message history instead of vanishing. A tool call the model was still in the middle of generating when the abort hit is dropped even then (its arguments may be truncated JSON); only tool calls that had already fully finished are included — e.g. if the model requested 3 tool calls and the abort landed while the 3rd was still streaming, only the first 2 come back. For a **non-streaming** call there's nothing to salvage (the API only returns a response once, in full) — abort there still surfaces as a thrown error, and `BaseAgent` discards that turn entirely rather than recording a broken one.
64
76
 
65
77
  `stop()` is a deprecated alias for `safeStop()` and will be removed in the 1.0.0 major release.
@@ -74,37 +86,50 @@ OrbitX never truncates or deletes conversation history. Compaction happens throu
74
86
 
75
87
  Calling `compact_memory`:
76
88
 
77
- 1. Replaces the running `memory` string with `new_memory`, which is injected into every future system prompt (`MEMORY:` section).
78
- 2. Clears `messagesCompact` — the *working* context sent to the provider each turn.
79
- 3. Leaves `messagesFull` — the complete, ever-appended history — untouched, so nothing is lost; it's just no longer replayed to the model every turn.
80
- 4. Rolls the current token counters into the lifetime totals (see [Token accounting](#token-accounting)) and resets them to zero.
89
+ 1. Replaces the running `compactMemory` string with `new_memory`, which is injected into every future system prompt (`MEMORY:` section). The cached system prompt is invalidated at the same time, so the very next turn's system prompt is rebuilt with the new summary rather than replaying the stale one.
90
+ 2. Moves everything currently in `messagesCompact` — the *working* context sent to the provider each turn — into `retiredMessages`, then clears `messagesCompact`. Nothing is lost, it's just retired from what gets replayed to the model every turn. Nothing needs to be separately "rolled into a lifetime total" — every message already carries its own `usage`, so `getFullTotalToken()` (see [Token accounting](#token-accounting)) just sums `retiredMessages` + `messagesCompact` directly.
91
+
92
+ Before each turn, `BaseAgent` also checks the current context size: the summed `inputMissTokens` of every message's `"main"` usage entry across `messagesCompact` (see [Token accounting](#token-accounting)) — a cheap proxy for context size, not a precise replay-cost measurement. When that sum exceeds `maxMemorizeToken`, the agent doesn't compact on the model's behalf — instead it appends a `role: "user"` message telling the model to call `compact_memory` before doing anything else, and loops back for another turn. This keeps compaction on the same native tool-call path the model already uses for everything else, rather than a side-channel the model never sees; a model that ignores the nudge (rare, since the instruction is explicit) will simply see it repeated on the next turn until it complies.
81
93
 
82
- `BaseAgent` also tracks two token totals per run: `currentInputMissTokens` (uncached input) and `currentLastOutputTokens` (the last turn's output). When their sum exceeds `maxMemorizeToken`, the agent doesn't compact on the model's behalf — instead it appends a `role: "user"` message telling the model to call `compact_memory` before doing anything else, and loops back for another turn. This keeps compaction on the same native tool-call path the model already uses for everything else, rather than a hidden side-channel; a model that ignores the nudge (rare, since the instruction is explicit) will simply see it repeated on the next turn until it complies.
94
+ This nudge message (and its matching `StreamCallback` chunk) is marked `hidden: true`, since it's `BaseAgent` talking to the model on the caller's behalf, not something a human said or the model produced — a chat UI built on this SDK should filter it out of what it shows an end user, the same way it wouldn't show raw system-prompt text. See [Streaming](./streaming.md) for the full `Message`/chunk field reference.
83
95
 
84
96
  `maxMemorizeToken`, if not passed explicitly, is derived as `contextWindow * safeUsageRatio` (default ratio `0.5`) from the main provider's `getCapabilities()` — so it scales automatically with whatever model you plug in.
85
97
 
86
98
  ## Persisting and resuming state
87
99
 
88
- `getCurrentAgentStates()` returns exactly the shape expected by the `initData` constructor option — `memory`, `messagesFull`, `messagesCompact`, the six main-loop token counters, and the two image-usage counters (`imageInputMissTokens`, `imageOutputTokens` — see [Token accounting](#token-accounting)). Serialize it (it's plain JSON) after any `run()` call, and pass it back into a fresh `BaseAgent`/`SimpleAgent` to resume — same conversation, same memory, same token history. See the example in [Getting Started](./getting-started.md#recovering-an-agents-state).
100
+ `getCurrentAgentStates()` returns exactly the shape expected by the `initData` constructor option — `compactMemory`, `retiredMessages`, `messagesCompact`. Serialize it (it's plain JSON) after any `run()` call, and pass it back into a fresh `BaseAgent`/`SimpleAgent` to resume — same conversation, same memory, same token history, since every message already carries its own `usage`. See the example in [Getting Started](./getting-started.md#recovering-an-agents-state).
89
101
 
90
- The two image counters are optional on `initData` — a snapshot taken before image-usage tracking existed still loads, with both defaulting to `0`.
102
+ A snapshot taken with an older SDK version, before per-message `usage` existed, still loads fine — messages with no `usage` field just contribute `0` to every token total below.
91
103
 
92
104
  ## Token accounting
93
105
 
94
- Four read methods, all returning `{ total, inputHit, inputMiss, output }`:
106
+ Token usage lives on `Message.usage` (`MessageUsage[]`), not on separate counters — `BaseAgent` populates it as `chat()` responses come back, and the four read methods below are pure derived sums over `retiredMessages`/`messagesCompact`. There's no rollover bookkeeping to keep in sync on compaction (see [Memory compaction](#memory-compaction)): a message lives in exactly one of `retiredMessages` (retired) or `messagesCompact` (active) at a time — they're disjoint, not overlapping — so "already compacted away" is simply `retiredMessages` itself, with no slicing required. The complete history at any point is the concatenation of the two, in that order, not `retiredMessages` alone.
107
+
108
+ Each entry in `Message.usage` is one of:
109
+
110
+ ```ts
111
+ { type: ProviderType; unit: "tokens"; inputMissTokens: number; inputCacheTokens: number; outputTokens: number }
112
+ { type: ProviderType; unit: "cost"; cost: number }
113
+ ```
114
+
115
+ `type` is the provider role that produced the entry (see [Provider roles](./providers.md#provider-roles) — `"main"`, `"image-describer"`, or anything else registered/looked up). `unit` distinguishes real token billing from anything that isn't naturally token-shaped — an image/audio/3D-generation call billed as a flat price reports `unit: "cost"` instead.
116
+
117
+ **Attribution** — a `type: "main"` entry is set only on the assistant message a `chat()` call produced, straight from that call's own `inputMissTokens`/`inputCacheTokens`/`outputTokens`. There's no splitting or attribution onto the user/tool messages that triggered the call, and no per-message context-size measurement — a user or tool message never carries a `"main"` entry. These are real, provider-reported numbers (Anthropic's `cache_read_input_tokens`/`cache_creation_input_tokens`, DeepSeek's `prompt_cache_hit_tokens`/`prompt_cache_miss_tokens`, OpenAI's `prompt_tokens_details.cached_tokens`), not a client-side guess — see [Providers](./providers.md) for what each one actually reports and Ollama's lack of any cache concept (`inputCacheTokens` always `0` there).
118
+
119
+ The compaction trigger (see [Memory compaction](#memory-compaction)) sums `inputMissTokens` across every assistant message's `"main"` entry in `messagesCompact` as a cheap proxy for context size — not a precise measurement of what replaying `messagesCompact` right now would actually cost, since each entry reflects the call that produced that one assistant message, not the whole context at that point.
120
+
121
+ Every other `type` entry comes from a tool calling `mcp.executeProvider(toolCallId, type, input)` (see [Tools](./tools.md#mcpexecuteprovider--calling-a-provider-from-inside-a-tool)) — but the entry itself is recorded by `BaseAgent`, the instant the real provider call resolves, never by the tool's own return value. This is deliberate: a tool can't under-report (or hide) what it spent, because it never holds the usage number to begin with — `BaseAgent` keys it by `toolCallId` and moves it onto that call's result `Message.usage` only after `execute()` returns, regardless of what the tool itself returns. `read-image`/`browser-screenshot`, for example, trigger a `type: "image-describer", unit: "tokens"` entry with no hit/miss split (each call is a fresh, standalone chat issued directly against the resolved provider by `BaseAgent`, so every input token counts as `inputMissTokens`).
95
122
 
96
- - `getCurrentTotalTokens()` — main-loop tokens used since the last memory compaction.
97
- - `getFullTotalTokens()` — main-loop tokens used across all *prior* compacted segments.
98
- - `getImageTotalTokens()` — tokens used by every `ImageDescriber` call this agent has made (see [Providers](./providers.md#two-provider-roles-main--image)), tracked separately since a one-off image-description call has no conversation history to compact and so doesn't fit the current/full split.
99
- - `getTotalTokens()` — the sum of all three (lifetime total for this agent instance, main loop and image calls combined).
123
+ `executeProvider` picks `unit: "tokens"` vs `unit: "cost"` dynamically from what the provider's `ChatResponse` actually reported, rather than assuming one or the other: if the response set `cost` (a flat, non-token price — see [`ChatResponse`](./providers.md#writing-a-custom-provider)), the entry is `unit: "cost"`; otherwise it falls back to the token fields (`inputMissTokens`/`inputCacheTokens`/`outputTokens`, each defaulting to `0` if the provider omitted them). Every built-in provider (Anthropic/OpenAI/DeepSeek/Ollama) is token-billed and always takes the token path — `cost` only comes into play for a custom provider registered under an image/video/audio-generation-style role that bills per-request instead of per-token.
100
124
 
101
- `inputHit`/`inputMiss` on the main-loop methods track cached vs. uncached input tokens, since the compacted-context-replay pattern means most of a long conversation's prefix is a cache hit on providers that support prompt caching — this is a heuristic `BaseAgent` computes itself from the growing/reset conversation history: whatever was `inputMiss` last turn rolls into `inputHit` this turn, and `inputMiss` becomes this turn's raw input token count.
125
+ **The four read methods:**
102
126
 
103
- `getImageTotalTokens()`'s `inputHit` is always `0` — each `ImageDescriber` call is a fresh, standalone chat with no prior turns to replay, so there's nothing for that heuristic to apply to. Every input token an image call reports counts as `inputMiss`.
127
+ - `getCompactTotalTokens(type: ProviderType)` / `getFullTotalToken(type: ProviderType)` — return `{ total, inputHit, inputMiss, output }`, summing `unit: "tokens"` entries matching `type` over `messagesCompact` only, or `retiredMessages` + `messagesCompact` (this agent's whole lifetime), respectively.
128
+ - `getCompactTotalCost(type: ProviderType)` / `getFullTotalCost(type: ProviderType)` — return a plain `number`, summing `unit: "cost"` entries matching `type` over the same two scopes.
104
129
 
105
130
  ## Instructions, skills, and memory in the system prompt
106
131
 
107
- The system prompt is built once (lazily, on first use) and never changes for the lifetime of an agent instance — a stable prefix, which matters for provider-side prompt caching. It's assembled from: a fixed "you have tools" preamble, each skill's name/description/instructions (see [Skills](./skills.md)), the current `MEMORY:` block, and your `instruction` string, in that order.
132
+ The system prompt is built lazily on first use and cached — a stable prefix between compactions, which matters for provider-side prompt caching. It's assembled from: a fixed "you have tools" preamble, each skill's name/description/instructions (see [Skills](./skills.md)), the current `MEMORY:` block (sourced from `compactMemory`), and your `instruction` string, in that order. Each `compact_memory` call invalidates the cache (see [Memory compaction](#memory-compaction)), so the next turn's system prompt is rebuilt with the fresh `MEMORY:` block instead of the one that was current when the prompt was first built.
108
133
 
109
134
  ## Multi-agent: `WorkerAgent` *(experimental)*
110
135
 
@@ -6,12 +6,11 @@ Flat index of every public export from `orbitx` (see `src/index.ts`), grouped by
6
6
 
7
7
  - `AIProvider` (abstract base class)
8
8
  - `OpenAIProvider`, `AnthropicProvider`, `DeepSeekProvider`, `OllamaProvider`
9
- - `resolveAgentProviders()`
10
- - Types: `AgentProviderRole`, `AgentProviderEntry`, `AgentProvidersInput`, `ResolvedAgentProviders`
11
- - Types: `ChatResponse`, `Message`, `StreamCallback`, `ToolSchema`, `ToolCallRequest`, `MessageContentPart`, `ProviderCapabilities`
9
+ - `resolveAgentProviders()`, `ProviderRegistry`
10
+ - Types: `AgentProviderEntry`, `AgentProvidersInput`
11
+ - Types: `ChatResponse`, `Message`, `MessageUsage`, `MessageUsageTokens`, `MessageUsageCost`, `StreamCallback`, `ToolSchema`, `ToolCallRequest`, `MessageContentPart`, `ProviderCapabilities`, `ProviderType` — see [Providers](./providers.md#provider-roles)
12
12
  - `resolveThinkEffortLevel()`, `clampThinkEffort()` — the universal 0-1 `thinkEffort` → provider-native-level mapping helpers; see [Providers](./providers.md#think-effort)
13
13
  - Type: `ThinkEffortLevel`
14
- - `ImageDescriber`, type `ImageDescription` — used internally by `BaseAgent` to describe image tool output via the `image` provider role; exported for direct use if you want to describe an image outside the agent loop. `describe()` returns an `ImageDescription` (`{ description, inputTokens, outputTokens }`), not a bare string.
15
14
  - `toOpenAIFunctionTools()` — translates a `ToolSchema[]` into OpenAI's function-calling format; exported for building custom providers.
16
15
 
17
16
  ## Agents — see [Agents](./agents.md)
@@ -23,21 +22,21 @@ Flat index of every public export from `orbitx` (see `src/index.ts`), grouped by
23
22
 
24
23
  ## Tools — see [Tools](./tools.md)
25
24
 
26
- - `MCPTool`, `MCPCustomClass`, `generateRefId()`, `normalizeToolOutput()`
25
+ - `MCPTool`, `generateRefId()`, `normalizeToolOutput()`
27
26
  - Types: `MCP`, `MCPToolOutput`
28
27
 
29
- Built-in tool factories, grouped by domain (each domain also exports a `*Tools()` array and a `*Interaction` EventEmitter):
28
+ Built-in tool factories, grouped by domain (each domain also exports a `*Tools()` array):
30
29
 
31
30
  | Domain | Exports |
32
31
  |---|---|
33
- | Filesystem | `FsReadFileTool`, `FsWriteFileTool`, `FsEditFileTool`, `FsListDirTool`, `FsCreateDirTool`, `FsDeleteTool`, `FsMoveTool`, `FsStatTool`, `FsTools`, `FsInteraction`, type `FsEvent` |
34
- | Bash | `BashRunTool`, `BashWaitTool`, `BashLogsTool`, `BashListTool`, `BashWriteInputTool`, `BashTerminateTool`, `BashTools`, `BashInteraction`, type `BashEvent` |
35
- | Browser | `BrowserCreateSessionTool`, `BrowserRemoveSessionTool`, `BrowserGetSessionsTool`, `BrowserNavigateTool`, `BrowserConsoleTool`, `BrowserInjectTool`, `BrowserReadTool`, `BrowserClickTool`, `BrowserFillTool`, `BrowserScrollInfoTool`, `BrowserScrollTool`, `BrowserNetworkStatusTool`, `BrowserNetworkTool`, `BrowserSubmitFormTool`, `BrowserScreenshotTool`, `BrowserTools`, `BrowserInteraction`, type `BrowserEvent` |
36
- | Todo | `TodoCreateListTool`, `TodoRemoveListTool`, `TodoGetListsTool`, `TodoGetListTool`, `TodoCreateTaskTool`, `TodoRemoveTaskTool`, `TodoCheckTaskTool`, `TodoTools`, `TodoInteraction`, types `TodoTask`, `TodoList`, `TodoEvent` |
37
- | Present | `PresentAddTool`, `PresentClearTool`, `PresentGetListTool`, `PresentTools`, `getPresentFolder()`, `PresentInteraction`, type `PresentEvent` |
38
- | Question/Answer | `QuestionAnswerTool`, `QuestionAnswerTools`, `QuestionAnswerInteraction`, type `QuestionAnswerEvent` |
32
+ | Filesystem | `FsReadFileTool`, `FsWriteFileTool`, `FsEditFileTool`, `FsListDirTool`, `FsCreateDirTool`, `FsDeleteTool`, `FsMoveTool`, `FsStatTool`, `FsTools` |
33
+ | Bash | `BashRunTool`, `BashWaitTool`, `BashLogsTool`, `BashListTool`, `BashWriteInputTool`, `BashTerminateTool`, `BashTools` |
34
+ | Browser | `BrowserCreateSessionTool`, `BrowserRemoveSessionTool`, `BrowserGetSessionsTool`, `BrowserNavigateTool`, `BrowserConsoleTool`, `BrowserInjectTool`, `BrowserReadTool`, `BrowserClickTool`, `BrowserFillTool`, `BrowserScrollInfoTool`, `BrowserScrollTool`, `BrowserNetworkStatusTool`, `BrowserNetworkTool`, `BrowserSubmitFormTool`, `BrowserScreenshotTool`, `BrowserTools` |
35
+ | Todo | `TodoCreateListTool`, `TodoRemoveListTool`, `TodoGetListsTool`, `TodoGetListTool`, `TodoCreateTaskTool`, `TodoRemoveTaskTool`, `TodoCheckTaskTool`, `TodoTools`, types `TodoTask`, `TodoList` |
36
+ | Present | `PresentAddTool`, `PresentClearTool`, `PresentGetListTool`, `PresentTools`, `getPresentFolder()` |
37
+ | Question/Answer | `QuestionAnswerTool`, `QuestionAnswerTools` |
39
38
  | Utility | `GetCurrentTimeTool`, `DelayTool`, `ReadImageTool`, `UtilTools` |
40
- | Multi-agent *(experimental)* | `AgentTools(availableAgents, options?)`, `AgentListTool`, `AgentHireTool`, `AgentPromptTool`, `AgentReportTool`, `AgentRegistry`, `AgentInteraction`, type `AgentInfo`, type `AgentEvent`, type `AgentToolsOptions` — see [Agents](./agents.md#multi-agent-workeragent-experimental) |
39
+ | Multi-agent *(experimental)* | `AgentTools(availableAgents, options?)`, `AgentListTool`, `AgentHireTool`, `AgentPromptTool`, `AgentReportTool`, `AgentRegistry`, type `AgentInfo`, type `AgentToolsOptions` — see [Agents](./agents.md#multi-agent-workeragent-experimental) |
41
40
 
42
41
  ## Skills — see [Skills](./skills.md)
43
42
 
@@ -51,4 +50,5 @@ Built-in tool factories, grouped by domain (each domain also exports a `*Tools()
51
50
  - `MCPComputer` *(experimental)*
52
51
  - `MCPStorage`, `MCPFSStorage`
53
52
  - `MCPRNG`
54
- - `MCPFilter`
53
+ - `MCPOutputFilter` (`MCPFilter` is a deprecated alias, removed in `1.0.0`)
54
+ - `MCPExecutionPolicy`, `MCPBypassExecutionPolicy`, type `MCPToolCallRequest`
@@ -49,7 +49,7 @@ Every provider implements the same `AIProvider` interface, so switching models i
49
49
 
50
50
  ## Recovering an agent's state
51
51
 
52
- `getCurrentAgentStates()` returns a plain JSON-serializable snapshot: message history, running memory summary, and token counters. Persist it anywhere; pass it back in as `initData` to resume the exact same agent later (after a process restart, in a new request handler, etc.):
52
+ `getCurrentAgentStates()` returns a plain JSON-serializable snapshot: message history (each message carrying its own token usage) and running memory summary. Persist it anywhere; pass it back in as `initData` to resume the exact same agent later (after a process restart, in a new request handler, etc.):
53
53
 
54
54
  ```ts
55
55
  import { writeFileSync, existsSync, readFileSync } from "fs";
@@ -69,7 +69,7 @@ const agent = new SimpleAgent({
69
69
  writeFileSync("./state.json", JSON.stringify(agent.getCurrentAgentStates()));
70
70
  ```
71
71
 
72
- Messages are only ever appended, never rewritten. When the conversation grows past the memory threshold, OrbitX keeps the full history on disk (`messagesFull`) but resets the model's *working* context from a running summary, so an agent can keep going indefinitely without losing the ability to act on what happened earlier. Mechanics are covered in [Agents](./agents.md#memory-compaction).
72
+ Messages are only ever appended, never rewritten. When the conversation grows past the memory threshold, OrbitX retires the working context into `retiredMessages` and resets the model's *active* context (`messagesCompact`) from a running summary (`compactMemory`), so an agent can keep going indefinitely without losing the ability to act on what happened earlier — nothing on disk is ever deleted, it just moves from one array to the other. Mechanics are covered in [Agents](./agents.md#memory-compaction).
73
73
 
74
74
  ## Next steps
75
75
 
@@ -24,14 +24,19 @@ Registers tools and listens on its `connection` for incoming tool-call requests,
24
24
  ### `MCPClient`
25
25
 
26
26
  ```ts
27
- new MCPClient(envID: string, connection: MCPConnection | MCPConnection[], storage?: MCPStorage, rng?: MCPRNG, mcpFilter?: MCPFilter);
27
+ new MCPClient(envID: string, connection: MCPConnection | MCPConnection[], storage?: MCPStorage, rng?: MCPRNG, mcpFilter?: MCPOutputFilter, executionPolicy?: MCPExecutionPolicy);
28
28
  mcpClient.getTools(): Promise<ToolSchema[]>;
29
- mcpClient.callTool(name: string, inputs: Record<string, any>): Promise<MCPToolOutput>;
29
+ mcpClient.callTool(name: string, inputs: Record<string, any>, toolCallId?: string): Promise<MCPToolOutput>;
30
+ mcpClient.setExecuteProviderHandler(handler: (toolCallId: string, type: ProviderType, input: Record<string, any>) => Promise<{ output: Record<string, any> }>): void;
30
31
  ```
31
32
 
33
+ `toolCallId` on `callTool()` and the handler installed via `setExecuteProviderHandler()` are what `BaseAgent` wires up automatically so `mcp.executeProvider(...)` works inside a tool's `execute()` — see [Custom `MCP` subclasses](#custom-mcp-subclasses) below. You don't need to call either yourself unless you're driving `MCPClient` outside of `BaseAgent`.
34
+
32
35
  This is what you pass into `BaseAgent({ mcpClient, ... })`. `envID` scopes storage/RNG state per logical environment/session — pass a stable id (e.g. a user or conversation id) if you want isolated tool state per agent instance sharing a server. Accepting an array of connections lets one client fan out across multiple servers.
33
36
 
34
- `mcpFilter` (an `MCPFilter`) redacts sensitive substrings/patterns from tool output before it reaches the model — see [`MCPFilter`](#mcpfilter-redacting-tool-output) below.
37
+ `mcpFilter` (an `MCPOutputFilter`) redacts sensitive substrings/patterns from tool output before it reaches the model — see [`MCPOutputFilter`](#mcpoutputfilter-redacting-tool-output) below.
38
+
39
+ `executionPolicy` (an `MCPExecutionPolicy`) decides whether a tool call is allowed to run at all, *before* it's dispatched — see [`MCPExecutionPolicy`](#mcpexecutionpolicy-gating-tool-calls) below.
35
40
 
36
41
  #### Registering tools directly on the client (bypassing the connection)
37
42
 
@@ -89,15 +94,56 @@ Requires the `aliafsordeh/orbitx-sandbox:0.1` image (pull it, or build it yourse
89
94
  - **`MCPStorage`** — abstract `{ get(key): Promise<string>; set(key, value): Promise<void> }`. `MCPFSStorage` is the default filesystem-backed implementation (`new MCPFSStorage(path?)`, defaults to a randomly-named folder under the OS temp directory). `MCPStorage` and `MCPFSStorage` are both exported if you want to implement your own backend (Redis, a database, etc.) or point the default one at a specific path.
90
95
  - **`MCPRNG`** — deterministic-ish id generator backed by an `MCPStorage` instance (used internally for things like `generateRefId()`, the helper behind ref ids in `BrowserReadTool`'s output).
91
96
 
92
- ## `MCPFilter` — redacting tool output
97
+ ## `MCPOutputFilter` — redacting tool output
93
98
 
94
99
  ```ts
95
- new MCPFilter(values: (string | RegExp)[]);
100
+ new MCPOutputFilter(values: (string | RegExp)[]);
96
101
  mcpFilter.filter(input: any): any; // replaces every match with "FILTERED_OUTPUT"
97
102
  ```
98
103
 
99
104
  Pass literal strings (e.g. an API key you never want echoed back to the model) or regexes (e.g. `/sk-[a-zA-Z0-9]{20,}/`) as the `mcpFilter` argument to `MCPClient` to scrub tool output before it's returned — useful when a tool might read a file or environment variable containing a secret.
100
105
 
106
+ > `MCPOutputFilter` was named `MCPFilter` before. The old name is still exported as a deprecated alias (`export const MCPFilter = MCPOutputFilter`) and will be removed in `1.0.0` — switch to `MCPOutputFilter` in new code.
107
+
108
+ ## `MCPExecutionPolicy` — gating tool calls
109
+
110
+ ```ts
111
+ abstract class MCPExecutionPolicy {
112
+ abstract authorize(request: MCPToolCallRequest): Promise<boolean> | boolean;
113
+ }
114
+ // MCPToolCallRequest = { toolName: string; inputs: Record<string, any>; envID: string; toolCallId?: string };
115
+ ```
116
+
117
+ Checked by `MCPClient.callTool()` before every tool call is dispatched — whether the tool is registered locally on the client or routed across a connection to a remote `MCPServer`. Returning (or resolving to) `false` throws before the call reaches the tool, and that error surfaces to the model as a normal tool-error result (`BaseAgent`'s dispatch loop catches it the same way it catches any other `callTool()` rejection).
118
+
119
+ `MCPClient` defaults to `MCPBypassExecutionPolicy`, a built-in no-op that authorizes every call — so this is entirely opt-in. Implement your own subclass of `MCPExecutionPolicy` to add an allowlist, rate limiting, per-tool confirmation, or any other trust policy, and pass it as the `executionPolicy` argument to `MCPClient`'s constructor:
120
+
121
+ ```ts
122
+ import { MCPClient, MCPExecutionPolicy, type MCPToolCallRequest } from "orbitx";
123
+
124
+ class AllowlistPolicy extends MCPExecutionPolicy {
125
+ #allowed: Set<string>;
126
+ constructor(allowed: string[]) {
127
+ super();
128
+ this.#allowed = new Set(allowed);
129
+ }
130
+ authorize({ toolName }: MCPToolCallRequest) {
131
+ return this.#allowed.has(toolName);
132
+ }
133
+ }
134
+
135
+ const mcpClient = new MCPClient(envID, connection, storage, rng, undefined, new AllowlistPolicy(["get_current_time"]));
136
+ ```
137
+
101
138
  ## Custom `MCP` subclasses
102
139
 
103
- Both `MCPServer` and `MCPClient` extend the abstract `MCP` class (`getStorage()`, `getRNG()`). Tool `execute` functions receive the calling `MCP` instance as their third argument, so a custom `MCPCustomClass`-based tool (see [Tools](./tools.md#the-mcptool-shape)) can reach storage/RNG scoped to whichever client dispatched the call.
140
+ Both `MCPServer` and `MCPClient` extend the abstract `MCP` class — `getStorage()`, `getRNG()`, and `executeProvider(toolCallId, type, input)`. Tool `execute` functions receive the calling `MCP` instance directly, as their 4th argument (after a `toolCallId` — see [Tools](./tools.md#the-mcptool-shape)), so a tool can reach storage/RNG scoped to whichever client dispatched the call.
141
+
142
+ `executeProvider` is where the two subclasses genuinely differ:
143
+
144
+ - **`MCPClient`** answers it in-process, by delegating to a handler `BaseAgent` installs on the client via `setExecuteProviderHandler(...)` at construction time. `MCPClient` itself never imports anything from `ai-provider.ts` — it just holds an opaque callback.
145
+ - **`MCPServer`** has no such handler — it never touches a provider directly. It proxies the request over its `MCPConnection` (an `executeProvider`/`executeProviderResponse` message pair, mirroring `toolCall`/`toolCallCallback` but initiated by the server instead of the client) to whichever `MCPClient` is attached, which runs the same installed handler and answers back.
146
+
147
+ So a **server-registered** tool — the kind meant to run inside `MCPComputer`'s sandbox, or in a genuinely separate process over IPC/WS — can still call `mcp.executeProvider(...)` and get a real result, without a provider instance (or the credentials it holds) ever crossing into that process. Only a scoped request/response for one call does. This is deliberate: filesystem/browser-touching tools like `ReadImageTool`/`BrowserScreenshotTool` are exactly the kind this doc already recommends sandboxing (see above), and they both need a provider to do their job.
148
+
149
+ That accessor also carries provider access (`agent.getProvider(type)`/`getProviders(type)`) — but **only for a tool dispatched by `MCPClient.callTool`'s local branch** (a tool registered directly on the client, per the section above). `MCPServer`'s dispatch (both the `toolCall` handler above and, transitively, anything a remote/sandboxed tool triggers) builds an accessor with no provider registry behind it at all, so `getProvider`/`getProviders` there always resolve `undefined`. This is deliberate, not a gap to work around: provider instances hold API keys, and `MCPServer` is precisely the thing that runs inside a separate process or an `MCPComputer` sandbox — credentials never need to, and never do, cross that boundary. A tool that needs to call a provider directly (e.g. to describe an image it just read) has to be registered on the `MCPClient`, in the same process as the agent that owns those providers.
package/docs/providers.md CHANGED
@@ -12,10 +12,12 @@ abstract class AIProvider {
12
12
  ): Promise<ChatResponse>;
13
13
 
14
14
  abstract getCapabilities(): ProviderCapabilities;
15
+
16
+ abstract setOption(key: string, value: unknown): void;
15
17
  }
16
18
  ```
17
19
 
18
- - `chat` sends the full message history (plus a provider-agnostic `tools` schema, when supplied) and returns a `ChatResponse` (`{ content, inputTokens, outputTokens, toolCalls? }`). If `streamCallback` is passed, text is streamed to it incrementally as it arrives — see [Streaming](./streaming.md).
20
+ - `chat` sends the full message history (plus a provider-agnostic `tools` schema, when supplied) and returns a `ChatResponse` (`{ content, inputMissTokens, inputCacheTokens, outputTokens, toolCalls? }`) — `inputMissTokens`/`inputCacheTokens` are real, provider-reported non-cached/cache-hit input token counts (see "User tracking & prompt caching" below), not a client-side estimate. If `streamCallback` is passed, text is streamed to it incrementally as it arrives — see [Streaming](./streaming.md).
19
21
  - `signal`, when passed and later aborted, cancels the in-flight request to the provider's API immediately rather than just abandoning it client-side — `BaseAgent.immediateStop()` uses this so the call stops generating (and billing for) further output right away. It doesn't retroactively waive input tokens or output already generated before the abort. Anthropic, OpenAI, and DeepSeek honor it for both streaming and non-streaming calls (their SDKs forward it straight to `fetch`). Ollama only honors it for streaming calls — its client has no way to abort a non-streaming request — so a `chat()` call made without a `streamCallback` against `OllamaProvider` can't be cancelled mid-flight; see the retry-loop guard in `ollama-provider.ts`.
20
22
  - **Streaming calls resolve normally on abort**, returning a `ChatResponse` built from whatever content/thinking/tool-calls were accumulated before the cutoff, instead of throwing — so text already delivered via `streamCallback` still ends up recorded rather than discarded. A tool call still being generated when the abort lands is dropped (its arguments may be truncated JSON); only tool calls that had already fully finished streaming are included in `toolCalls`.
21
23
  - **Non-streaming calls still throw on abort** — there's no partial response to salvage since the API only returns once, in full — and `BaseAgent` discards that turn.
@@ -23,15 +25,48 @@ abstract class AIProvider {
23
25
 
24
26
  ```ts
25
27
  interface ProviderCapabilities {
26
- supportsTools: boolean; // must be true — BaseAgent requires native tool-calling
27
- supportsImages: boolean; // whether this provider/model accepts image content
28
- contextWindow: number; // total context window, in tokens
29
- safeUsageRatio?: number; // fraction of contextWindow safe to fill before compacting (default 0.5)
28
+ supportsTools: boolean; // must be true — BaseAgent requires native tool-calling
29
+ supportsImages: boolean; // whether this provider/model accepts image content
30
+ contextWindow: number; // total context window, in tokens
31
+ safeUsageRatio?: number; // fraction of contextWindow safe to fill before compacting (default 0.5)
32
+ supportsThinking?: boolean; // whether reasoning/thinking text is surfaced — see "Think effort" below
33
+ maxOutputTokens?: number; // the model's max output tokens per response, where confirmed — not yet enforced anywhere, reserved for a future release
34
+ supportsVideo?: boolean; // whether this provider/model accepts video content
35
+ supportsAudio?: boolean; // whether this provider/model accepts audio content
36
+ supportsImageGeneration?: boolean; // whether this provider/model can generate new images
37
+ supportsImageEditing?: boolean; // whether this provider/model can edit an existing image
38
+ supportsVideoGeneration?: boolean; // whether this provider/model can generate new video
39
+ supportsVideoEditing?: boolean; // whether this provider/model can edit existing video
40
+ supportsAudioGeneration?: boolean; // whether this provider/model can generate new audio (speech, music, sound effects)
41
+ supportsAudioDesign?: boolean; // whether this provider/model can design/produce sound effects or soundscapes
42
+ supportsAudioClone?: boolean; // whether this provider/model can clone a voice from a reference sample
43
+ supports3DModelGeneration?: boolean; // whether this provider/model can generate a new 3D model
30
44
  }
31
45
  ```
32
46
 
47
+ These flags describe what the model itself can natively do — they're unrelated to `ProviderType` (see "Provider roles" below), which is just a role name used to look a provider up in a `ProviderRegistry`. A provider registered under the `"image-generation"` role doesn't need `supportsImageGeneration: true` (or vice versa) — the role name and the capability flag are independent. All four built-in providers are chat/vision LLMs, not generation APIs, so every one of the new flags above is always `false` on them.
48
+
33
49
  `BaseAgent.run()` throws immediately if `getCapabilities().supportsTools` is `false` — the legacy JSON-in-text tool-call convention has been removed from the main loop. All providers default `supportsTools` to `true` except Ollama, which defaults it to `false` (many locally-hosted models don't support native function calling; pass `{ supportsTools: true }` explicitly if your model does).
34
50
 
51
+ ## Changing an option without reconstructing
52
+
53
+ `setOption(key, value)` mutates an already-constructed provider in place — e.g. `provider.setOption("thinkEffort", 0.8)` — instead of building a new one. Every built-in provider only allows keys it already reads fresh on every `chat()` call (never something baked into derived state at construction, like `contextWindow`); an unrecognized key throws.
54
+
55
+ | Provider | Settable keys |
56
+ |---|---|
57
+ | Anthropic | `thinkEffort`, `userId`, `disablePromptCaching`, `maxTokens`, `supportsTools` |
58
+ | OpenAI | `thinkEffort`, `userId`, `supportsTools` |
59
+ | DeepSeek | `thinkEffort`, `userId`, `supportsTools` |
60
+ | Ollama | `thinkEffort`, `supportsTools` |
61
+
62
+ ## Usage on the streaming path
63
+
64
+ OpenAI and DeepSeek's Chat-Completions-style streaming protocol doesn't compute token usage until a turn is fully complete — it arrives once, in a special final chunk (`stream_options: { include_usage: true }`), alongside `done: true`. Every chunk before that has `usage: { inputMissTokens: 0, inputCacheTokens: 0, outputTokens: 0 }` — not because those numbers are unknown-but-estimated, but because the API genuinely hasn't computed them yet. `OpenAIProvider` and `DeepSeekProvider` both trust this real, provider-reported final chunk directly — no local estimation involved on either. DeepSeek's final chunk includes its own `prompt_cache_hit_tokens`/`prompt_cache_miss_tokens` fields (falling back to `prompt_tokens_details.cached_tokens` if those aren't present), parsed the same way real cache data is read from any other provider.
65
+
66
+ If a call is cut off before that final chunk arrives (e.g. `BaseAgent.immediateStop()` aborting the connection to stop paying for further output), the real usage number simply never gets computed or sent for either provider — not partially, not late, just never — so `inputMissTokens`/`inputCacheTokens`/`outputTokens` stay `0` on `ChatResponse` too in that case. Anthropic doesn't have this problem (it streams real usage incrementally, see [Streaming](./streaming.md#usage-chunks)); Ollama has the same final-chunk-only limitation but isn't covered by what's described here.
67
+
68
+ This is the only usage source DeepSeek's streaming path has, so it's treated as the real number, not flagged as a lesser "estimate" — whatever lands on the final `done: true` chunk (and the returned `ChatResponse`) is what a consumer should use for billing/accounting, the same as it would for any other provider's real, API-reported usage. The two sides of that number are revealed at different points, though: `outputTokens` grows on every streamed chunk as content arrives, since it's cheap to recompute from what's been generated so far; `inputMissTokens`/`inputCacheTokens` are only computed once, on that final chunk, since the input side never changes mid-call and there's no reason to resend the same number on every content delta.
69
+
35
70
  ## Think effort
36
71
 
37
72
  Reasoning/thinking models each expose a different native "how hard should the model think" knob — OpenAI and Ollama use `low`/`medium`/`high`, DeepSeek uses `none`/`low`/`high`/`max`, Anthropic uses a numeric thinking-token budget. OrbitX replaces all of that with one universal option on every provider's constructor:
@@ -60,6 +95,19 @@ Per-provider specifics:
60
95
 
61
96
  See [Streaming](./streaming.md#thinking-chunks) for how (and whether) a given provider's reasoning text is observable while it streams.
62
97
 
98
+ ## User tracking & prompt caching
99
+
100
+ Every provider's constructor accepts `userId?: string` — an opaque end-user identifier forwarded to the underlying API for that provider's own abuse-monitoring/analytics, same universal-option pattern as `thinkEffort`: pass it uniformly and providers that have no equivalent field just ignore it.
101
+
102
+ | Provider | Where `userId` goes | Notes |
103
+ |---|---|---|
104
+ | Anthropic | `metadata.user_id` on every request | Anthropic's documented field for this purpose. |
105
+ | OpenAI | `user_id` on every request | Sent as an extra body field alongside the standard params. |
106
+ | DeepSeek | `user_id` on every request | Same mechanism as OpenAI (DeepSeek's API is OpenAI-compatible). |
107
+ | Ollama | Ignored | Accepted in the options type for interface parity only — Ollama's API has no equivalent field. |
108
+
109
+ **Anthropic prompt caching is on by default.** Every request marks the last content block with an ephemeral `cache_control` breakpoint, so a growing conversation gets served from cache instead of being fully reprocessed each turn — pass `disablePromptCaching: true` to send every request uncached. DeepSeek's cache is automatic server-side (no `cache_control` equivalent to toggle) and reports its own explicit `prompt_cache_hit_tokens`/`prompt_cache_miss_tokens`. OpenAI's cache is also automatic, reporting hits via `prompt_tokens_details.cached_tokens`. Ollama has no cache concept at all — `inputCacheTokens` is always `0`.
110
+
63
111
  ## Built-in providers
64
112
 
65
113
  ### Ollama
@@ -71,6 +119,7 @@ new OllamaProvider(model: string, host = "http://localhost:11434", options?: {
71
119
  supportsTools?: boolean; // default false
72
120
  contextWindow?: number; // default 32_000
73
121
  thinkEffort?: number; // 0-1, see "Think effort" above
122
+ userId?: string; // accepted for interface parity only — ignored, Ollama has no equivalent field
74
123
  });
75
124
  ```
76
125
 
@@ -86,12 +135,13 @@ new DeepSeekProvider(apiKey: string, model = "deepseek-v4-flash", options?: {
86
135
  supportsImages?: boolean; // default: true only for deepseek-v4-flash-vision-exp
87
136
  contextWindow?: number; // default 1_000_000 (deepseek-v4-flash / deepseek-v4-flash-vision-exp / deepseek-v4-pro)
88
137
  thinkEffort?: number; // 0-1, see "Think effort" above
138
+ userId?: string; // see "User tracking & prompt caching" above
89
139
  });
90
140
  ```
91
141
 
92
142
  Uses the `openai` SDK pointed at `https://api.deepseek.com` (DeepSeek's API is OpenAI-compatible). `getCapabilities().supportsImages` is `false` for every model except `deepseek-v4-flash-vision-exp`, which is `deepseek-v4-flash` plus native image input (same context window, same reasoning support) — pass `supportsImages: true` explicitly if a future model adds vision support before this SDK's allowlist is updated.
93
143
 
94
- Token usage (`inputTokens`/`outputTokens`) is only accurate on the non-streaming path (`chat()` called without `streamCallback`), which reads `response.usage` directly. On the streaming path this provider deliberately does not set `stream_options: { include_usage: true }`, so DeepSeek never sends a usage-bearing chunk and both counts report as `0`. This matters most for `ImageDescriber`, which always calls `chat()` without a `streamCallback` (see [Token accounting](./agents.md#token-accounting)) — so image-description usage is captured correctly even though live agent streaming isn't.
144
+ `stream_options: { include_usage: true }` is set on every streaming call, so `inputMissTokens`/`inputCacheTokens`/`outputTokens` are accurate on both the streaming and non-streaming paths — DeepSeek sends a usage-bearing final chunk (including its own `prompt_cache_hit_tokens`/`prompt_cache_miss_tokens` split) the same way OpenAI does.
95
145
 
96
146
  ### Anthropic
97
147
 
@@ -101,9 +151,12 @@ import { AnthropicProvider } from "orbitx";
101
151
  new AnthropicProvider(apiKey: string, model = "claude-sonnet-5", options?: {
102
152
  supportsTools?: boolean; // default true
103
153
  supportsImages?: boolean; // default true
104
- contextWindow?: number; // default from a per-model table, else 200_000
154
+ contextWindow?: number; // default from a per-model table, else 200_000; an explicit value here always wins over extendedContext below
105
155
  maxTokens?: number; // output token cap, default 4096
106
156
  thinkEffort?: number; // 0-1, see "Think effort" above
157
+ userId?: string; // see "User tracking & prompt caching" above
158
+ disablePromptCaching?: boolean; // default false — prompt caching is on by default, see above
159
+ extendedContext?: boolean | string; // default false/undefined — see "Context window" below
107
160
  });
108
161
  ```
109
162
 
@@ -120,27 +173,47 @@ new OpenAIProvider(apiKey: string, model = "gpt-5", options?: {
120
173
  contextWindow?: number; // default from a per-model table, else 128_000
121
174
  baseURL?: string; // point at an OpenAI-compatible endpoint
122
175
  thinkEffort?: number; // 0-1, see "Think effort" above — ignored on non-reasoning models
176
+ userId?: string; // see "User tracking & prompt caching" above
123
177
  });
124
178
  ```
125
179
 
126
180
  Uses the Chat Completions API (not the Responses API). `baseURL` makes this usable against any OpenAI-compatible provider, not just OpenAI itself.
127
181
 
128
- ## Two provider roles: main + image
182
+ ## Provider roles
183
+
184
+ An agent's providers are registered under open-ended role names, not a fixed pair. `"main"` — the provider driving the agent loop itself — is the only one that's required; every other role is optional and looked up on demand by whatever tool needs it.
129
185
 
130
- An agent has exactly two provider "roles":
186
+ ```ts
187
+ export type ProviderType =
188
+ | "main"
189
+ | "image-describer"
190
+ | "image-generation"
191
+ | "image-editing"
192
+ | "video-describer"
193
+ | "video-generation"
194
+ | "video-editing"
195
+ | "audio-describer"
196
+ | "audio-generation"
197
+ | "audio-design"
198
+ | "audio-clone"
199
+ | "3d-model-generator"
200
+ | "llm-low"
201
+ | "llm-medium"
202
+ | "llm-high"
203
+ | (string & {});
204
+ ```
131
205
 
132
- - **`main`** — drives the agent loop itself (required).
133
- - **`image`** — describes image tool output (e.g. a screenshot from `BrowserScreenshotTool`) into text, so the main conversation doesn't have to carry raw image bytes. Optional.
206
+ `ProviderType` (exported from `orbitx`) is the documented starting vocabulary for every role name — not a closed union, and purely for naming/registry lookup. It has no relationship to `ProviderCapabilities` (see above) — a role name doesn't imply anything about what the provider registered under it can actually do. The trailing `(string & {})` (not bare `string`) is what makes the open-endedness work: a plain `| string` union collapses to just `string` and an editor stops suggesting the literals at all, while intersecting with `{}` keeps TS from widening it away, so a custom name still type-checks the moment nothing here fits. `llm-low`/`medium`/`high` are a self-declared overall quality/durability tier (cheap-and-good-enough vs. worth relying on for longer/harder tasks) — not a specific claim about reasoning power.
134
207
 
135
- Pass a single provider to use it for both roles (if it supports images, it's also used as the image describer; otherwise images are dropped with an explanatory error message back to the model). Pass an array to split them:
208
+ Pass a single provider to use it as `main` (same as today); pass an array of `{ type, provider, default? }` entries to register more:
136
209
 
137
210
  ```ts
138
211
  import { BaseAgent, DeepSeekProvider, OllamaProvider } from "orbitx";
139
212
 
140
213
  const agent = new BaseAgent({
141
214
  aiProvider: [
142
- { type: "main", aiProvider: new DeepSeekProvider("api-key", "deepseek-v4-flash") },
143
- { type: "image", aiProvider: new OllamaProvider("llava:latest") },
215
+ { type: "main", provider: new DeepSeekProvider("api-key", "deepseek-v4-flash") },
216
+ { type: "image-describer", provider: new OllamaProvider("llava:latest") },
144
217
  ],
145
218
  instruction: "You are a helpful assistant.",
146
219
  mcpClient,
@@ -148,8 +221,34 @@ const agent = new BaseAgent({
148
221
  });
149
222
  ```
150
223
 
151
- This resolution is done by `resolveAgentProviders()` (`AgentProvidersInput = AIProvider | AgentProviderEntry[]`), exported from `orbitx` if you need it directly. There is no third "utils" role — every tool/skill is included in the system prompt from the start, so there's nothing left for a separate pass to decide.
224
+ `provider` can also be an array — a pool of candidates for that role — with `default` (0-based, defaults to `0`) picking which one is used when code needs exactly one. The rest of the pool is there to be enumerated later; nothing built-in does smarter selection than "the default" yet.
225
+
226
+ ### Resolving a provider by role
227
+
228
+ `ProviderRegistry.getProvider(type)` tries an exact role-name match first (that role's pool default). If nothing is registered under `type`, it falls back to scanning every provider registered under *any* role for the first one whose `getCapabilities()` reports the matching capability flag — not a generic capability bag keyed by `ProviderType` (that's gone; `ProviderType` is purely a registry role name and has no relationship to what a model can do), but a fixed, small mapping from a handful of well-known role names to their one corresponding `ProviderCapabilities` flag:
229
+
230
+ | Role (`ProviderType`) | Capability flag checked in the fallback |
231
+ |---|---|
232
+ | `"image-describer"` | `supportsImages` |
233
+ | `"image-generation"` | `supportsImageGeneration` |
234
+ | `"image-editing"` | `supportsImageEditing` |
235
+ | `"video-describer"` | `supportsVideo` |
236
+ | `"video-generation"` | `supportsVideoGeneration` |
237
+ | `"video-editing"` | `supportsVideoEditing` |
238
+ | `"audio-describer"` | `supportsAudio` |
239
+ | `"audio-generation"` | `supportsAudioGeneration` |
240
+ | `"audio-design"` | `supportsAudioDesign` |
241
+ | `"audio-clone"` | `supportsAudioClone` |
242
+ | `"3d-model-generator"` | `supports3DModelGeneration` |
243
+
244
+ This is why a single vision-capable `main` provider "just works" as an image describer with zero extra config: nothing needs to be registered under `"image-describer"` explicitly — the fallback finds `main` because its `getCapabilities().supportsImages` is `true`. Role names outside this table (`"main"`, `"llm-low"`/`"medium"`/`"high"`, or any custom string) have no capability equivalent, so the fallback never applies to them — an unregistered role like that returns `undefined` from `getProvider` regardless of what any registered provider can do.
245
+
246
+ `BaseAgent` is the only thing that ever calls `getProvider` directly — a tool never touches a `ProviderRegistry`, or a raw provider, itself. It reaches this indirectly via `mcp.executeProvider(toolCallId, type, input)` (see [Tools](./tools.md#mcpexecuteprovider--calling-a-provider-from-inside-a-tool)), which works the same way whether the tool is registered on the local `MCPClient` or a sandboxed/remote `MCPServer` — the request is always resolved on `BaseAgent`'s side, never handed across as a live object.
247
+
248
+ `resolveAgentProviders()` (`AgentProvidersInput = AIProvider | AgentProviderEntry[]`) builds the `ProviderRegistry` this all runs on — both are exported from `orbitx` if you need them directly (`getMain()`, `getProvider(type)`, `getProviders(type)` for the full pool under an exact role). There is no separate "utils" role — every tool/skill is included in the system prompt from the start, so there's nothing left for a separate pass to decide.
152
249
 
153
250
  ## Writing a custom provider
154
251
 
155
252
  Extend `AIProvider` and implement `chat`/`getCapabilities`. Follow an existing provider (e.g. `src/core/ollama-provider.ts`) as a template: translate `Message[]` to your API's wire format, stream text chunks to `streamCallback` as they arrive, accumulate tool-call deltas and emit them on the final chunk, and translate `ToolSchema[]` into your API's function-calling format (see `src/core/tool-schema-translator.ts` for existing OpenAI/Anthropic translators you can reuse or reference).
253
+
254
+ `inputMissTokens`/`inputCacheTokens`/`outputTokens` on the returned `ChatResponse` are optional — every built-in provider is token-billed and always sets them, but a provider registered under a per-request-billed role (e.g. `image-generation`, `audio-clone`) should omit them and set `cost` instead. `BaseAgent#executeProvider` (see [Tools](./tools.md#mcpexecuteprovider--calling-a-provider-from-inside-a-tool)) checks `cost` first and records a `unit: "cost"` usage entry when it's present, falling back to the token fields (defaulting any missing one to `0`) otherwise — see [Token accounting](./agents.md#token-accounting).
package/docs/streaming.md CHANGED
@@ -11,12 +11,14 @@ type StreamCallback = (chunk: {
11
11
  toolCallId?: string; // on "tool"-role chunks
12
12
  toolName?: string; // on "tool"-role chunks
13
13
  thinking?: string; // reasoning-text delta, mirroring `content` — see "Thinking chunks" below
14
+ usage?: { inputMissTokens: number; inputCacheTokens: number; outputTokens: number }; // see "Usage chunks" below
15
+ hidden?: boolean; // true on messages BaseAgent injected itself
14
16
  }) => Promise<void> | void;
15
17
  ```
16
18
 
17
19
  ## What you actually receive, turn by turn
18
20
 
19
- 1. **`role: "user"`** — one chunk, `done: true`, echoing the prompt that was just added to history (either the one you passed to `run()`, or a queued one — see [Agents](./agents.md#the-run-loop)).
21
+ 1. **`role: "user"`** — one chunk, `done: true`, echoing the prompt that was just added to history (either the one you passed to `run()`, or a queued one — see [Agents](./agents.md#the-run-loop)). One exception: the forced-compaction nudge (see [Agents](./agents.md#memory-compaction)) is also a `role: "user"` chunk, but carries `hidden: true` since it's `BaseAgent` talking to the model, not the actual user — check that flag before showing a `"user"` chunk to an end user.
20
22
  2. **`role: "assistant"`** — the model's reply, streamed incrementally: zero or more `done: false` chunks each carrying the next slice of `content` as it arrives from the provider, followed by one final `done: true` chunk. `content` is delta text, not the accumulated total — append it yourself if you need the running message.
21
23
  - If the model requested tool calls this turn, each one is emitted individually, mid-stream, as soon as *that* call finishes generating — `toolCalls: [thatOneCall]` on a `done: false` chunk. See "Incremental tool-call chunks" below.
22
24
  3. **`role: "tool"`** — one chunk per dispatched tool call, `done: true`, with `content` set to the tool's result text and `toolCallId`/`toolName` identifying which call it answers. Emitted after the tool has actually finished executing (there is no streaming/progress signal for tool execution itself through this callback — see below for how to get that separately).
@@ -49,6 +51,21 @@ Detection differs by provider, since not every API has an explicit "this call is
49
51
  | OpenAI / DeepSeek | Inferred: the API streams one call's arguments at a time with no end marker, so a call is treated as finished the instant the *next* call's first delta (carrying a fresh `id`) arrives, or the stream ends (for the last/only call). |
50
52
  | Ollama | Ollama sends each tool call already fully formed (not built up token by token), so it's emitted the moment it's seen — deduplicated in case the same call reappears in a later chunk. |
51
53
 
54
+ ## Usage chunks
55
+
56
+ Every `assistant`-role chunk carries `usage` — not just the ones with a fresh number to report. Where a value isn't known yet, it's an explicit `0`, never an absent/undefined field, so consuming code can always read `chunk.usage.outputTokens` etc. without separately guarding for `usage` or its fields being missing. This is the same `streamCallback` passed into `run()`, forwarded straight through to the provider, so external code (e.g. something watching an external credit/cost budget, deciding when to call `immediateStop()`) sees it too — no separate channel.
57
+
58
+ Every number here is real, straight from whatever the provider reports — nothing in this SDK estimates or guesses a token count. What differs is *when* each field stops being a placeholder `0` and starts reflecting the API's real number:
59
+
60
+ | Provider | Mid-stream `usage` | Final `usage` (on `done: true`) |
61
+ |---|---|---|
62
+ | Anthropic | `inputMissTokens`/`inputCacheTokens` are real from the very first chunk (before any content) — that's simply when its protocol first has them, not something computed early on purpose. `outputTokens` updates on every delta. | Same numbers, just the last update. |
63
+ | OpenAI | All three fields are `0` on every chunk before the last one (`include_usage` is honored correctly, but only delivers on completion — there's nothing to report early). | Real, billed numbers for all three fields. If the call is cut off before this chunk arrives, `inputMissTokens`/`inputCacheTokens`/`outputTokens` come back `0` on `ChatResponse` too — the API never computed them, so there's nothing to report. |
64
+ | DeepSeek | Same as OpenAI — `0` on every chunk before the last one. | Real, billed numbers, including its own `prompt_cache_hit_tokens`/`prompt_cache_miss_tokens` for the cache split. Same `0`-on-abort behavior as OpenAI if the call is cut off first. |
65
+ | Ollama | All three fields are `0` on every chunk before the last one — no live signal exists for this provider at all. | Real. |
66
+
67
+ Practical implication: for Anthropic and DeepSeek you get a live, reactable signal before a turn finishes (immediately for Anthropic's input side, throughout for either provider's output side); for OpenAI there's no live signal at all today — you only find out after the turn finishes, or get a hard `0` if it was cut off first.
68
+
52
69
  ## Minimal consumer
53
70
 
54
71
  ```ts
@@ -110,7 +127,7 @@ Once a turn finishes, its accumulated reasoning is available two ways on both `C
110
127
 
111
128
  ## What's *not* in the stream today
112
129
 
113
- - **No live per-tool progress channel through `StreamCallback`.** For domains that emit their own progress (browser navigation, long-running bash processes), subscribe to that domain's `*Interaction` `EventEmitter` directly instead (e.g. `BrowserInteraction`, `BashInteraction`) — see [Tools](./tools.md).
130
+ - **No live per-tool progress channel through `StreamCallback`.** There's no built-in way to observe a long-running tool call (browser navigation, a bash process) mid-flight — `execute()` runs to completion and its return value is the only thing surfaced.
114
131
  - **Tool calls still only appear once, fully assembled** — see above. `thinkEffort`/thinking chunks don't change that.
115
132
 
116
133
  If your use case depends on either of these, check the project's issue tracker / recent changes before assuming they're unavailable — this area of the SDK is actively evolving.