@namzu/sdk 3.3.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/CHANGELOG.md +430 -0
  2. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  3. package/dist/agents/SupervisorAgent.js +58 -2
  4. package/dist/agents/SupervisorAgent.js.map +1 -1
  5. package/dist/agents/__tests__/run-agent.test.d.ts +2 -0
  6. package/dist/agents/__tests__/run-agent.test.d.ts.map +1 -0
  7. package/dist/agents/__tests__/run-agent.test.js +143 -0
  8. package/dist/agents/__tests__/run-agent.test.js.map +1 -0
  9. package/dist/agents/__tests__/supervisor-coordinator-registration.test.d.ts +2 -0
  10. package/dist/agents/__tests__/supervisor-coordinator-registration.test.d.ts.map +1 -0
  11. package/dist/agents/__tests__/supervisor-coordinator-registration.test.js +122 -0
  12. package/dist/agents/__tests__/supervisor-coordinator-registration.test.js.map +1 -0
  13. package/dist/agents/index.d.ts +2 -0
  14. package/dist/agents/index.d.ts.map +1 -1
  15. package/dist/agents/index.js +1 -0
  16. package/dist/agents/index.js.map +1 -1
  17. package/dist/agents/runAgent.d.ts +104 -0
  18. package/dist/agents/runAgent.d.ts.map +1 -0
  19. package/dist/agents/runAgent.js +92 -0
  20. package/dist/agents/runAgent.js.map +1 -0
  21. package/dist/connector/mcp/__tests__/prompt-as-tool.test.js +1 -1
  22. package/dist/connector/mcp/__tests__/prompt-as-tool.test.js.map +1 -1
  23. package/dist/connector/mcp/prompt-adapter.d.ts.map +1 -1
  24. package/dist/connector/mcp/prompt-adapter.js +16 -9
  25. package/dist/connector/mcp/prompt-adapter.js.map +1 -1
  26. package/dist/provider/__tests__/thinking-support.test.d.ts +2 -0
  27. package/dist/provider/__tests__/thinking-support.test.d.ts.map +1 -0
  28. package/dist/provider/__tests__/thinking-support.test.js +47 -0
  29. package/dist/provider/__tests__/thinking-support.test.js.map +1 -0
  30. package/dist/provider/thinking-support.d.ts +30 -0
  31. package/dist/provider/thinking-support.d.ts.map +1 -0
  32. package/dist/provider/thinking-support.js +32 -0
  33. package/dist/provider/thinking-support.js.map +1 -0
  34. package/dist/public-runtime.d.ts +5 -2
  35. package/dist/public-runtime.d.ts.map +1 -1
  36. package/dist/public-runtime.js +11 -2
  37. package/dist/public-runtime.js.map +1 -1
  38. package/dist/public-tools.d.ts +2 -0
  39. package/dist/public-tools.d.ts.map +1 -1
  40. package/dist/public-tools.js +3 -0
  41. package/dist/public-tools.js.map +1 -1
  42. package/dist/public-types.d.ts +1 -1
  43. package/dist/public-types.d.ts.map +1 -1
  44. package/dist/registry/index.d.ts +1 -1
  45. package/dist/registry/index.d.ts.map +1 -1
  46. package/dist/registry/index.js +1 -1
  47. package/dist/registry/index.js.map +1 -1
  48. package/dist/registry/tool/execute.d.ts +15 -0
  49. package/dist/registry/tool/execute.d.ts.map +1 -1
  50. package/dist/registry/tool/execute.js +19 -0
  51. package/dist/registry/tool/execute.js.map +1 -1
  52. package/dist/runtime/query/__tests__/steering-reaches-the-model.test.d.ts +2 -0
  53. package/dist/runtime/query/__tests__/steering-reaches-the-model.test.d.ts.map +1 -0
  54. package/dist/runtime/query/__tests__/steering-reaches-the-model.test.js +94 -0
  55. package/dist/runtime/query/__tests__/steering-reaches-the-model.test.js.map +1 -0
  56. package/dist/runtime/query/__tests__/steering.test.d.ts +2 -0
  57. package/dist/runtime/query/__tests__/steering.test.d.ts.map +1 -0
  58. package/dist/runtime/query/__tests__/steering.test.js +92 -0
  59. package/dist/runtime/query/__tests__/steering.test.js.map +1 -0
  60. package/dist/runtime/query/index.d.ts +10 -0
  61. package/dist/runtime/query/index.d.ts.map +1 -1
  62. package/dist/runtime/query/index.js +1 -0
  63. package/dist/runtime/query/index.js.map +1 -1
  64. package/dist/runtime/query/iteration/phases/context.d.ts +8 -0
  65. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  66. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  67. package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
  68. package/dist/runtime/query/iteration/phases/tool-review.js +7 -1
  69. package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
  70. package/dist/runtime/query/steering.d.ts +81 -0
  71. package/dist/runtime/query/steering.d.ts.map +1 -0
  72. package/dist/runtime/query/steering.js +77 -0
  73. package/dist/runtime/query/steering.js.map +1 -0
  74. package/dist/sandbox/__tests__/exec-cancellation.test.d.ts +2 -0
  75. package/dist/sandbox/__tests__/exec-cancellation.test.d.ts.map +1 -0
  76. package/dist/sandbox/__tests__/exec-cancellation.test.js +59 -0
  77. package/dist/sandbox/__tests__/exec-cancellation.test.js.map +1 -0
  78. package/dist/sandbox/provider/local.d.ts.map +1 -1
  79. package/dist/sandbox/provider/local.js +30 -5
  80. package/dist/sandbox/provider/local.js.map +1 -1
  81. package/dist/session/workspace/shared-run.d.ts +31 -0
  82. package/dist/session/workspace/shared-run.d.ts.map +1 -1
  83. package/dist/session/workspace/shared-run.js +31 -0
  84. package/dist/session/workspace/shared-run.js.map +1 -1
  85. package/dist/tools/__tests__/file-tool-containment.test.d.ts +2 -0
  86. package/dist/tools/__tests__/file-tool-containment.test.d.ts.map +1 -0
  87. package/dist/tools/__tests__/file-tool-containment.test.js +134 -0
  88. package/dist/tools/__tests__/file-tool-containment.test.js.map +1 -0
  89. package/dist/tools/__tests__/sandboxed-search.test.js +51 -0
  90. package/dist/tools/__tests__/sandboxed-search.test.js.map +1 -1
  91. package/dist/tools/__tests__/untrusted-envelope.test.d.ts +2 -0
  92. package/dist/tools/__tests__/untrusted-envelope.test.d.ts.map +1 -0
  93. package/dist/tools/__tests__/untrusted-envelope.test.js +55 -0
  94. package/dist/tools/__tests__/untrusted-envelope.test.js.map +1 -0
  95. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.d.ts +2 -0
  96. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.d.ts.map +1 -0
  97. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js +114 -0
  98. package/dist/tools/builtins/__tests__/edit-insert-reachable.test.js.map +1 -0
  99. package/dist/tools/builtins/__tests__/edit.test.js +9 -2
  100. package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
  101. package/dist/tools/builtins/__tests__/write-file.test.js +59 -0
  102. package/dist/tools/builtins/__tests__/write-file.test.js.map +1 -1
  103. package/dist/tools/builtins/edit.d.ts.map +1 -1
  104. package/dist/tools/builtins/edit.js +66 -14
  105. package/dist/tools/builtins/edit.js.map +1 -1
  106. package/dist/tools/builtins/ls.d.ts +1 -1
  107. package/dist/tools/builtins/ls.d.ts.map +1 -1
  108. package/dist/tools/builtins/ls.js +95 -0
  109. package/dist/tools/builtins/ls.js.map +1 -1
  110. package/dist/tools/builtins/read-file.d.ts.map +1 -1
  111. package/dist/tools/builtins/read-file.js +3 -2
  112. package/dist/tools/builtins/read-file.js.map +1 -1
  113. package/dist/tools/builtins/write-file.d.ts.map +1 -1
  114. package/dist/tools/builtins/write-file.js +26 -12
  115. package/dist/tools/builtins/write-file.js.map +1 -1
  116. package/dist/tools/coordinator/__tests__/agent.test.js +6 -1
  117. package/dist/tools/coordinator/__tests__/agent.test.js.map +1 -1
  118. package/dist/tools/coordinator/__tests__/ask-user-question.test.js +47 -0
  119. package/dist/tools/coordinator/__tests__/ask-user-question.test.js.map +1 -1
  120. package/dist/tools/coordinator/__tests__/empty-roster.test.d.ts +2 -0
  121. package/dist/tools/coordinator/__tests__/empty-roster.test.d.ts.map +1 -0
  122. package/dist/tools/coordinator/__tests__/empty-roster.test.js +97 -0
  123. package/dist/tools/coordinator/__tests__/empty-roster.test.js.map +1 -0
  124. package/dist/tools/coordinator/agent.d.ts +22 -8
  125. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  126. package/dist/tools/coordinator/agent.js +45 -2
  127. package/dist/tools/coordinator/agent.js.map +1 -1
  128. package/dist/tools/coordinator/index.d.ts.map +1 -1
  129. package/dist/tools/coordinator/index.js +80 -3
  130. package/dist/tools/coordinator/index.js.map +1 -1
  131. package/dist/tools/paths.d.ts +40 -0
  132. package/dist/tools/paths.d.ts.map +1 -1
  133. package/dist/tools/paths.js +70 -1
  134. package/dist/tools/paths.js.map +1 -1
  135. package/dist/tools/untrusted-envelope.d.ts +55 -0
  136. package/dist/tools/untrusted-envelope.d.ts.map +1 -0
  137. package/dist/tools/untrusted-envelope.js +65 -0
  138. package/dist/tools/untrusted-envelope.js.map +1 -0
  139. package/dist/types/agent/manager.d.ts +11 -4
  140. package/dist/types/agent/manager.d.ts.map +1 -1
  141. package/dist/types/agent/supervisor.d.ts +9 -0
  142. package/dist/types/agent/supervisor.d.ts.map +1 -1
  143. package/dist/types/common/index.d.ts +12 -0
  144. package/dist/types/common/index.d.ts.map +1 -1
  145. package/dist/types/common/index.js +7 -0
  146. package/dist/types/common/index.js.map +1 -1
  147. package/dist/types/provider/chat.d.ts +57 -5
  148. package/dist/types/provider/chat.d.ts.map +1 -1
  149. package/dist/types/provider/index.d.ts +1 -1
  150. package/dist/types/provider/index.d.ts.map +1 -1
  151. package/dist/types/sandbox/index.d.ts +10 -0
  152. package/dist/types/sandbox/index.d.ts.map +1 -1
  153. package/dist/types/sandbox/index.js.map +1 -1
  154. package/dist/types/toolset/index.d.ts +0 -24
  155. package/dist/types/toolset/index.d.ts.map +1 -1
  156. package/package.json +1 -1
  157. package/src/agents/SupervisorAgent.ts +57 -2
  158. package/src/agents/__tests__/run-agent.test.ts +165 -0
  159. package/src/agents/__tests__/supervisor-coordinator-registration.test.ts +148 -0
  160. package/src/agents/index.ts +2 -0
  161. package/src/agents/runAgent.ts +179 -0
  162. package/src/connector/mcp/__tests__/prompt-as-tool.test.ts +1 -1
  163. package/src/connector/mcp/prompt-adapter.ts +19 -9
  164. package/src/provider/__tests__/thinking-support.test.ts +60 -0
  165. package/src/provider/thinking-support.ts +37 -0
  166. package/src/public-runtime.ts +11 -0
  167. package/src/public-tools.ts +4 -0
  168. package/src/public-types.ts +7 -1
  169. package/src/registry/index.ts +1 -1
  170. package/src/registry/tool/execute.ts +23 -0
  171. package/src/runtime/query/__tests__/steering-reaches-the-model.test.ts +117 -0
  172. package/src/runtime/query/__tests__/steering.test.ts +121 -0
  173. package/src/runtime/query/index.ts +12 -0
  174. package/src/runtime/query/iteration/phases/context.ts +9 -0
  175. package/src/runtime/query/iteration/phases/tool-review.ts +7 -1
  176. package/src/runtime/query/steering.ts +135 -0
  177. package/src/sandbox/__tests__/exec-cancellation.test.ts +72 -0
  178. package/src/sandbox/provider/local.ts +30 -4
  179. package/src/session/workspace/shared-run.ts +31 -0
  180. package/src/tools/__tests__/file-tool-containment.test.ts +190 -0
  181. package/src/tools/__tests__/sandboxed-search.test.ts +66 -0
  182. package/src/tools/__tests__/untrusted-envelope.test.ts +82 -0
  183. package/src/tools/builtins/__tests__/edit-insert-reachable.test.ts +166 -0
  184. package/src/tools/builtins/__tests__/edit.test.ts +10 -2
  185. package/src/tools/builtins/__tests__/write-file.test.ts +80 -0
  186. package/src/tools/builtins/edit.ts +69 -14
  187. package/src/tools/builtins/ls.ts +102 -0
  188. package/src/tools/builtins/read-file.ts +3 -2
  189. package/src/tools/builtins/write-file.ts +26 -12
  190. package/src/tools/coordinator/__tests__/agent.test.ts +6 -1
  191. package/src/tools/coordinator/__tests__/ask-user-question.test.ts +58 -0
  192. package/src/tools/coordinator/__tests__/empty-roster.test.ts +118 -0
  193. package/src/tools/coordinator/agent.ts +72 -11
  194. package/src/tools/coordinator/index.ts +86 -3
  195. package/src/tools/paths.ts +83 -1
  196. package/src/tools/untrusted-envelope.ts +78 -0
  197. package/src/types/agent/manager.ts +11 -4
  198. package/src/types/agent/supervisor.ts +10 -0
  199. package/src/types/common/index.ts +20 -0
  200. package/src/types/provider/chat.ts +61 -5
  201. package/src/types/provider/index.ts +1 -0
  202. package/src/types/sandbox/index.ts +10 -0
  203. package/src/types/toolset/index.ts +0 -25
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { assertThinkingUnsupported } from '../thinking-support.js'
4
+
5
+ /**
6
+ * `thinking` sits on `ChatCompletionParams`, so every driver accepts it. Six
7
+ * of the seven in this repo did not implement it, and five of those six simply
8
+ * dropped the field: the caller got an ordinary completion with an empty
9
+ * `reasoning` array, indistinguishable from a model that chose not to reason.
10
+ *
11
+ * One driver already refused instead, with the reasoning written out. The rule
12
+ * was decided once and applied once — so it moved here, where a new driver
13
+ * inherits it rather than re-deciding it.
14
+ */
15
+ describe('a driver that cannot think says so', () => {
16
+ it('refuses a manual thinking request', () => {
17
+ expect(() =>
18
+ assertThinkingUnsupported('TestProvider', { thinking: { type: 'enabled' } }),
19
+ ).toThrow(/TestProvider does not implement thinking/)
20
+ })
21
+
22
+ it('refuses an adaptive one too', () => {
23
+ // Both are a request to think. Refusing one and dropping the other
24
+ // would leave exactly the silence this exists to remove.
25
+ expect(() =>
26
+ assertThinkingUnsupported('TestProvider', { thinking: { type: 'adaptive' } }),
27
+ ).toThrow(/does not implement thinking/)
28
+ })
29
+
30
+ it('names the driver, not just the problem', () => {
31
+ // In a multi-provider setup this is the difference between a bug
32
+ // report about the model and a one-line config fix.
33
+ expect(() =>
34
+ assertThinkingUnsupported('BedrockProvider', { thinking: { type: 'adaptive' } }),
35
+ ).toThrow(/BedrockProvider/)
36
+ })
37
+
38
+ it('says what silence would have looked like', () => {
39
+ let message = ''
40
+ try {
41
+ assertThinkingUnsupported('TestProvider', { thinking: { type: 'enabled' } })
42
+ } catch (err) {
43
+ message = (err as Error).message
44
+ }
45
+ expect(message).toContain('empty reasoning list')
46
+ expect(message).toContain('Drop `thinking`')
47
+ })
48
+
49
+ it('honours an explicit disable as a no-op', () => {
50
+ // A config shared across providers that says "do not think" should not
51
+ // fail on the ones that were never going to.
52
+ expect(() =>
53
+ assertThinkingUnsupported('TestProvider', { thinking: { type: 'disabled' } }),
54
+ ).not.toThrow()
55
+ })
56
+
57
+ it('does nothing when the caller said nothing', () => {
58
+ expect(() => assertThinkingUnsupported('TestProvider', {})).not.toThrow()
59
+ })
60
+ })
@@ -0,0 +1,37 @@
1
+ import type { ThinkingConfig } from '../types/provider/index.js'
2
+
3
+ /**
4
+ * Refuse a thinking request a driver does not implement.
5
+ *
6
+ * The failure this prevents is silence. `thinking` sits on
7
+ * `ChatCompletionParams`, so every driver accepts it; a driver that does not
8
+ * implement it drops the field and returns an ordinary completion with an
9
+ * empty `reasoning` array. The caller cannot tell that apart from a model that
10
+ * simply chose not to reason — the request looks honoured and the answer looks
11
+ * like an answer.
12
+ *
13
+ * Refusing names the driver instead, which is the difference between a bug
14
+ * report about the model and a one-line configuration fix.
15
+ *
16
+ * **Turning thinking OFF is honoured as a no-op**, because that is the state a
17
+ * driver without thinking is already in. A config shared across providers that
18
+ * says `{type: 'disabled'}` should not fail on the ones that were never going
19
+ * to think.
20
+ *
21
+ * This lived as a private copy inside one driver while five others dropped the
22
+ * field silently. It is here so a new driver inherits the rule instead of
23
+ * re-deciding it.
24
+ *
25
+ * @param driverName Named in the error, so the reader knows which provider in
26
+ * a multi-provider setup refused.
27
+ */
28
+ export function assertThinkingUnsupported(
29
+ driverName: string,
30
+ params: { thinking?: ThinkingConfig },
31
+ ): void {
32
+ const type = params.thinking?.type
33
+ if (type !== 'enabled' && type !== 'adaptive') return
34
+ throw new Error(
35
+ `${driverName} does not implement thinking. Silently ignoring the request would return an ordinary completion with an empty reasoning list, which reads as "the model did not reason" rather than "this driver cannot ask it to". Drop \`thinking\`, or use a driver that implements it.`,
36
+ )
37
+ }
@@ -67,7 +67,14 @@ export { extractFinalResponse } from './utils/conversation.js'
67
67
  // ─── router, runtime, run ────────────────────────────────────────────────
68
68
 
69
69
  export { resolveTaskModel } from './router/task-router.js'
70
+ // Every driver accepts `thinking`; one that does not implement it must
71
+ // refuse rather than drop it. Shared so a new driver inherits the rule.
72
+ export { assertThinkingUnsupported } from './provider/thinking-support.js'
70
73
  export { drainQuery, query } from './runtime/query/index.js'
74
+ // Mid-run guidance. A host holds the channel and the loop drains it at the
75
+ // tool-result boundary; see the module for why that is the only legal slot.
76
+ export { SteeringBinding, attachSteering, formatSteeringNote } from './runtime/query/steering.js'
77
+ export type { SteeringChannel } from './runtime/query/steering.js'
71
78
  export { createMockBidiProvider, startBidiRun } from './runtime/bidi/index.js'
72
79
  export { ContextCache } from './runtime/query/context-cache.js'
73
80
  export {
@@ -135,6 +142,9 @@ export {
135
142
  PipelineAgent,
136
143
  ReactiveAgent,
137
144
  RouterAgent,
145
+ // The short path: provider + model + prompt. Assembles the identity and
146
+ // budgets `drainQuery` requires and hands the generated identity back.
147
+ runAgent,
138
148
  SupervisorAgent,
139
149
  } from './agents/index.js'
140
150
 
@@ -158,6 +168,7 @@ export {
158
168
  PluginRegistry,
159
169
  Registry,
160
170
  ToolCatalog,
171
+ ToolNameCollisionError,
161
172
  ToolRegistry,
162
173
  createToolCatalogFromRegistry,
163
174
  loadingFromAvailability,
@@ -12,6 +12,10 @@
12
12
  // ─── Tool definition primitive ───────────────────────────────────────────
13
13
 
14
14
  export { defineTool } from './tools/defineTool.js'
15
+ // A host that surfaces its own untrusted content to a model needs the same
16
+ // framing the kernel applies to connector prompts and delegated results.
17
+ export { neutralizeEnvelopeDelimiter, wrapUntrusted } from './tools/untrusted-envelope.js'
18
+ export type { UntrustedEnvelope } from './tools/untrusted-envelope.js'
15
19
 
16
20
  // ─── Built-in tools ──────────────────────────────────────────────────────
17
21
 
@@ -99,7 +99,13 @@ export type { ContextCacheConfig, PromptCacheInput } from './runtime/query/conte
99
99
 
100
100
  export type { LimitCheckResult, LimitCheckerState, RunReporter } from './run/index.js'
101
101
 
102
- export type { DefineAgentOptions, Disposable } from './agents/index.js'
102
+ export type {
103
+ AgentIdentity,
104
+ DefineAgentOptions,
105
+ Disposable,
106
+ RunAgentOptions,
107
+ RunAgentResult,
108
+ } from './agents/index.js'
103
109
 
104
110
  export type {
105
111
  ActivityEvent,
@@ -2,7 +2,7 @@ export { Registry } from './Registry.js'
2
2
  export { ManagedRegistry } from './ManagedRegistry.js'
3
3
  export type { ManagedRegistryConfig } from './ManagedRegistry.js'
4
4
 
5
- export { ToolRegistry } from './tool/execute.js'
5
+ export { ToolNameCollisionError, ToolRegistry } from './tool/execute.js'
6
6
  export type { ToolExecutionResult } from './tool/execute.js'
7
7
  export {
8
8
  ToolCatalog,
@@ -83,6 +83,29 @@ export function assertToolName(name: string): void {
83
83
  )
84
84
  }
85
85
 
86
+ /**
87
+ * Two sources contributed the same tool name and neither may take it.
88
+ *
89
+ * Named, and carrying the name, for the reason `DuplicateProviderError` is:
90
+ * a host that wants to handle this — fall back to its own tool, log and
91
+ * continue, surface it in a config error — has to be able to catch it
92
+ * narrowly rather than match on message text. It also names both remedies,
93
+ * because a hard collision policy without a way to decline would make
94
+ * shadowing-by-name the only way to say "I do not want this tool", which is
95
+ * precisely what now throws.
96
+ */
97
+ export class ToolNameCollisionError extends Error {
98
+ readonly toolName: string
99
+
100
+ constructor(toolName: string, context: string) {
101
+ super(
102
+ `Tool name "${toolName}" is already registered by this host, and ${context} will not replace it. Rename the host tool, or decline the one being mounted with runtimeToolOverrides: { "${toolName}": "disabled" }.`,
103
+ )
104
+ this.name = 'ToolNameCollisionError'
105
+ this.toolName = toolName
106
+ }
107
+ }
108
+
86
109
  /**
87
110
  * Append a tool's declared return shape to its description.
88
111
  *
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { z } from 'zod'
3
+
4
+ import { MockLLMProvider, registerMock } from '../../../provider/index.js'
5
+ import { ToolRegistry } from '../../../registry/index.js'
6
+ import {
7
+ generateProjectId,
8
+ generateSessionId,
9
+ generateTenantId,
10
+ generateThreadId,
11
+ } from '../../../utils/id.js'
12
+ import { drainQuery } from '../index.js'
13
+ import { SteeringBinding } from '../steering.js'
14
+
15
+ /**
16
+ * The unit tests next door prove `attachSteering` builds the right message.
17
+ * They would all pass with the loop never calling it — which is the exact
18
+ * shape of defect this repo keeps finding, and the reason this file drives a
19
+ * real run instead.
20
+ *
21
+ * A steer queued while a tool is running must be visible in the messages the
22
+ * NEXT model call receives, or the channel is another declaration nothing
23
+ * drives.
24
+ */
25
+
26
+ registerMock()
27
+
28
+ async function runWithSteer(steerDuringTool?: string) {
29
+ const steering = new SteeringBinding()
30
+
31
+ const tools = new ToolRegistry()
32
+ tools.register({
33
+ name: 'inspect',
34
+ description: 'looks at something',
35
+ inputSchema: z.object({}),
36
+ execute: async () => {
37
+ // Queued from inside the tool, which is when a host would type it:
38
+ // the batch is in flight and there is no legal slot for a user
39
+ // message until it settles.
40
+ if (steerDuringTool) steering.steer(steerDuringTool)
41
+ return { success: true, output: 'inspection done' }
42
+ },
43
+ } as never)
44
+
45
+ const provider = new MockLLMProvider({
46
+ turns: [
47
+ { toolCalls: [{ id: 'c1', name: 'inspect', rawArguments: '{}' }] },
48
+ { text: 'finished' },
49
+ ],
50
+ })
51
+
52
+ await drainQuery({
53
+ provider,
54
+ tools,
55
+ agentId: 'a',
56
+ agentName: 'A',
57
+ messages: [{ role: 'user', content: 'go' }],
58
+ workingDirectory: process.cwd(),
59
+ runConfig: {
60
+ model: 'mock-model',
61
+ tokenBudget: 100_000,
62
+ timeoutMs: 30_000,
63
+ maxIterations: 4,
64
+ },
65
+ projectId: generateProjectId(),
66
+ sessionId: generateSessionId(),
67
+ threadId: generateThreadId(),
68
+ tenantId: generateTenantId(),
69
+ steering,
70
+ } as never)
71
+
72
+ return { provider, steering }
73
+ }
74
+
75
+ /** Every message body the model saw on its Nth call, flattened to text. */
76
+ function bodiesOn(provider: MockLLMProvider, call: number): string {
77
+ return (provider.requests[call]?.messages ?? [])
78
+ .map((m) => (typeof m.content === 'string' ? m.content : JSON.stringify(m.content)))
79
+ .join('\n')
80
+ }
81
+
82
+ describe('guidance queued during a tool batch reaches the model', () => {
83
+ it('is in the messages of the next model call', async () => {
84
+ const { provider } = await runWithSteer('actually, check the tests as well')
85
+
86
+ expect(provider.requests.length).toBeGreaterThan(1)
87
+ expect(bodiesOn(provider, 1)).toContain('actually, check the tests as well')
88
+ })
89
+
90
+ it('rides on the tool result rather than as a separate turn', async () => {
91
+ const { provider } = await runWithSteer('check the tests as well')
92
+
93
+ const second = provider.requests[1]?.messages ?? []
94
+ const carrier = second.find((m) =>
95
+ typeof m.content === 'string' ? m.content.includes('check the tests as well') : false,
96
+ )
97
+
98
+ // The slot matters: a `tool_use` block must be answered by a
99
+ // `tool_result` with the same id, so a user turn wedged in here is
100
+ // rejected by the provider outright.
101
+ expect(carrier?.role).toBe('tool')
102
+ expect(String(carrier?.content)).toContain('inspection done')
103
+ })
104
+
105
+ it('is delivered once and leaves the channel empty', async () => {
106
+ const { steering } = await runWithSteer('one time only')
107
+
108
+ expect(steering.pending).toBe(false)
109
+ })
110
+
111
+ it('changes nothing when nobody steers', async () => {
112
+ const { provider } = await runWithSteer()
113
+
114
+ expect(bodiesOn(provider, 1)).toContain('inspection done')
115
+ expect(bodiesOn(provider, 1)).not.toContain('operator')
116
+ })
117
+ })
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import type { Message } from '../../../types/message/index.js'
4
+ import { SteeringBinding, attachSteering, formatSteeringNote } from '../steering.js'
5
+
6
+ /**
7
+ * `AgentManager.queueMessage` / `drainMessages` have existed for a while and
8
+ * nothing in the iteration loop ever read them — the type says so outright.
9
+ * So a host watching a run go wrong could cancel it, throwing away every tool
10
+ * result already paid for, or reject through the review gate, which only
11
+ * works when a call happens to be pending approval and says "no" when the
12
+ * host meant "yes, but read this first".
13
+ *
14
+ * The delivery is the interesting part. A `tool_use` block must be answered
15
+ * by a `tool_result` with the same id, so there is no legal slot for a user
16
+ * message mid-batch; this codebase had already worked that out for denials,
17
+ * which carry their reason inside the `tool_result` precisely because that is
18
+ * where the model looks.
19
+ */
20
+
21
+ const toolMessage = (id: string, content: string): Message =>
22
+ ({ role: 'tool', content, toolCallId: id, timestamp: 1 }) as unknown as Message
23
+
24
+ const assistantMessage = (content: string): Message =>
25
+ ({ role: 'assistant', content, timestamp: 1 }) as unknown as Message
26
+
27
+ describe('steering a running turn', () => {
28
+ it('appends the guidance to the last tool result', () => {
29
+ const channel = new SteeringBinding()
30
+ channel.steer('check the tests too')
31
+
32
+ const out = attachSteering([toolMessage('a', 'first'), toolMessage('b', 'second')], channel)
33
+
34
+ expect(out[0]?.content).toBe('first')
35
+ // The LAST one: it is the final thing the model reads before deciding
36
+ // what to do next, where the first would be buried under every later
37
+ // result.
38
+ expect(out[1]?.content).toContain('second')
39
+ expect(out[1]?.content).toContain('check the tests too')
40
+ })
41
+
42
+ it('labels the guidance as the operator speaking, not the tool', () => {
43
+ const channel = new SteeringBinding()
44
+ channel.steer('stop and ask me first')
45
+
46
+ const out = attachSteering([toolMessage('a', 'output')], channel)
47
+
48
+ // Unlabelled it would read as something `bash` said.
49
+ expect(out[0]?.content).toContain('[Message from the operator')
50
+ })
51
+
52
+ it('accumulates repeated calls in order rather than replacing', () => {
53
+ const channel = new SteeringBinding()
54
+ channel.steer('first correction')
55
+ channel.steer('second correction')
56
+
57
+ const out = attachSteering([toolMessage('a', 'output')], channel)
58
+
59
+ const text = String(out[0]?.content)
60
+ expect(text.indexOf('first correction')).toBeLessThan(text.indexOf('second correction'))
61
+ })
62
+
63
+ it('ignores empty and whitespace-only guidance', () => {
64
+ const channel = new SteeringBinding()
65
+ channel.steer(' ')
66
+ channel.steer('')
67
+
68
+ expect(channel.pending).toBe(false)
69
+ expect(attachSteering([toolMessage('a', 'output')], channel)[0]?.content).toBe('output')
70
+ })
71
+
72
+ it('drains the channel, so guidance is delivered once', () => {
73
+ const channel = new SteeringBinding()
74
+ channel.steer('once')
75
+
76
+ attachSteering([toolMessage('a', 'output')], channel)
77
+ const second = attachSteering([toolMessage('b', 'later')], channel)
78
+
79
+ expect(channel.pending).toBe(false)
80
+ expect(second[0]?.content).toBe('later')
81
+ })
82
+
83
+ it('keeps guidance queued when the batch has no tool result to carry it', () => {
84
+ const channel = new SteeringBinding()
85
+ channel.steer('for the next turn')
86
+
87
+ const out = attachSteering([assistantMessage('just text')], channel)
88
+
89
+ expect(out[0]?.content).toBe('just text')
90
+ // Not dropped. A turn that called no tools has nothing in flight, so
91
+ // the guidance belongs to the next one.
92
+ expect(channel.pending).toBe(true)
93
+ })
94
+
95
+ it('leaves non-text tool content alone and re-queues rather than corrupting it', () => {
96
+ const channel = new SteeringBinding()
97
+ channel.steer('guidance')
98
+ const structured = {
99
+ role: 'tool',
100
+ content: [{ type: 'image', source: 'x' }],
101
+ toolCallId: 'a',
102
+ timestamp: 1,
103
+ } as unknown as Message
104
+
105
+ const out = attachSteering([structured], channel)
106
+
107
+ expect(out[0]?.content).toEqual([{ type: 'image', source: 'x' }])
108
+ expect(channel.pending).toBe(true)
109
+ })
110
+
111
+ it('does nothing at all without a channel', () => {
112
+ const messages = [toolMessage('a', 'output')]
113
+
114
+ expect(attachSteering(messages, undefined)).toBe(messages)
115
+ })
116
+
117
+ it('formats a note that names who is speaking', () => {
118
+ expect(formatSteeringNote('hello')).toContain('operator')
119
+ expect(formatSteeringNote('hello')).toContain('hello')
120
+ })
121
+ })
@@ -98,6 +98,7 @@ import {
98
98
  recoverCompletedCalls,
99
99
  unansweredToolCalls,
100
100
  } from './resume-pending.js'
101
+ import type { SteeringChannel } from './steering.js'
101
102
  import { ToolGrantSet } from './tool-grants.js'
102
103
  import { createToolPause } from './tool-pause.js'
103
104
  import { ToolingBootstrap } from './tooling.js'
@@ -151,6 +152,16 @@ export interface QueryParams {
151
152
  */
152
153
  questionParks?: QuestionParkBinding
153
154
 
155
+ /**
156
+ * Channel a host uses to hand guidance to the running turn.
157
+ *
158
+ * Optional and additive: absent leaves the loop byte-identical. Present,
159
+ * anything queued during a tool batch is appended to that batch's last
160
+ * tool result — the only slot a provider will accept text in mid-batch,
161
+ * and the one the model already reads for tool outcomes.
162
+ */
163
+ steering?: SteeringChannel
164
+
154
165
  /**
155
166
  * The registry a re-entered `ask_user_question` reads its answer from.
156
167
  *
@@ -827,6 +838,7 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
827
838
  abortController: ctx.abortController,
828
839
  log: ctx.log,
829
840
  resumeHandler: params.resumeHandler,
841
+ ...(params.steering ? { steering: params.steering } : {}),
830
842
  checkpointMgr,
831
843
  planManager: ctx.planManager,
832
844
  taskGateway: params.taskGateway,
@@ -32,6 +32,7 @@ import type { CheckpointManager } from '../../checkpoint.js'
32
32
  import type { EmitEvent } from '../../events.js'
33
33
  import type { ToolExecutor } from '../../executor.js'
34
34
  import type { GuardCoordinator } from '../../guard.js'
35
+ import type { SteeringChannel } from '../../steering.js'
35
36
  import type { ToolGrantSet } from '../../tool-grants.js'
36
37
 
37
38
  export interface LaunchedTaskMeta {
@@ -89,6 +90,14 @@ export interface IterationContext {
89
90
  readonly abortController: AbortController
90
91
  readonly log: Logger
91
92
  readonly resumeHandler: ResumeHandler
93
+
94
+ /**
95
+ * Guidance a host may hand to the turn while it runs.
96
+ *
97
+ * Absent means the loop behaves exactly as it always has — nothing is
98
+ * drained and no tool result is extended.
99
+ */
100
+ readonly steering?: SteeringChannel
92
101
  readonly checkpointMgr: CheckpointManager
93
102
  readonly planManager: PlanManager
94
103
 
@@ -2,6 +2,7 @@ import type { ChatCompletionResponse } from '../../../../types/provider/index.js
2
2
  import type { RunEvent } from '../../../../types/run/index.js'
3
3
  import type { VerificationGate } from '../../../../verification/index.js'
4
4
  import type { ToolCallDenials } from '../../executor.js'
5
+ import { attachSteering } from '../../steering.js'
5
6
  import { type IterationContext, awaitDecisionDurably } from './context.js'
6
7
 
7
8
  interface VerificationAwareContext extends IterationContext {
@@ -82,7 +83,12 @@ export async function* runToolReview(
82
83
  const batch = await ctx.toolExecutor.executeBatch(response, denials)
83
84
  toolMs += Date.now() - startedAt
84
85
  executed = batch.results
85
- for (const msg of batch.messages) {
86
+ // Guidance the host queued while this batch was running rides out on
87
+ // the last result. This is the only legal slot for it: a `tool_use`
88
+ // block must be answered by a `tool_result` with the same id, so a
89
+ // user message wedged between them is rejected by the provider. Same
90
+ // delivery a denial already uses, without the refusal.
91
+ for (const msg of attachSteering(batch.messages, ctx.steering)) {
86
92
  ctx.runMgr.pushMessage(msg)
87
93
  }
88
94
  }
@@ -0,0 +1,135 @@
1
+ import type { Message } from '../../types/message/index.js'
2
+
3
+ /**
4
+ * Guidance a host hands to a turn that is already running.
5
+ *
6
+ * The gap this closes is narrow and was documented rather than fixed:
7
+ * `AgentManager` has had `queueMessage` / `drainMessages` for a while, and
8
+ * nothing in the iteration loop ever read them — the type says so in as many
9
+ * words. So a host watching a run go the wrong way had two options, and both
10
+ * are worse than they sound. Cancel and start over throws away every tool
11
+ * result the run had already paid for. Reject through the review gate only
12
+ * works if a tool call happens to be pending approval, and it says "no" when
13
+ * the host wanted to say "yes, but look at this first".
14
+ *
15
+ * **Why the text rides on a tool result rather than arriving as a user
16
+ * message.** A `tool_use` block must be answered by a `tool_result` with the
17
+ * same id — providers reject a user turn wedged between them — so there is no
18
+ * legal place to insert a message mid-batch at all. The slot that already
19
+ * exists is the tool result itself, and this codebase had already worked that
20
+ * out for a neighbouring case: a denied call carries its reason INSIDE the
21
+ * `tool_result`, and `executor.ts` notes that this is also what makes a
22
+ * rejection *steer*, because the model reads it in the slot it already
23
+ * attends to for tool outcomes. Steering is the same delivery with the
24
+ * refusal removed.
25
+ *
26
+ * **What it deliberately is not.** It does not interrupt. The batch in flight
27
+ * finishes, and the guidance lands where the model looks next. A host that
28
+ * wants the current work stopped wants `AbortSignal`, which is a different
29
+ * question with a different answer — and conflating the two is how "please
30
+ * also check the tests" ends up killing a half-written file.
31
+ */
32
+ export interface SteeringChannel {
33
+ /**
34
+ * Queue guidance for the running turn.
35
+ *
36
+ * Repeated calls before the next drain accumulate in order rather than
37
+ * replacing each other: two corrections typed a second apart are two
38
+ * things the model should see, and keeping only the last one silently
39
+ * discards a host's instruction.
40
+ *
41
+ * Empty and whitespace-only text is ignored, so a stray keystroke does
42
+ * not append a blank line to a tool result.
43
+ */
44
+ steer(text: string): void
45
+
46
+ /** Take everything queued, leaving the channel empty. */
47
+ drain(): string | undefined
48
+
49
+ /** True while guidance is queued and undelivered. */
50
+ readonly pending: boolean
51
+ }
52
+
53
+ export class SteeringBinding implements SteeringChannel {
54
+ private queued: string[] = []
55
+
56
+ steer(text: string): void {
57
+ const trimmed = text.trim()
58
+ if (!trimmed) return
59
+ this.queued.push(trimmed)
60
+ }
61
+
62
+ drain(): string | undefined {
63
+ if (this.queued.length === 0) return undefined
64
+ const joined = this.queued.join('\n')
65
+ this.queued = []
66
+ return joined
67
+ }
68
+
69
+ get pending(): boolean {
70
+ return this.queued.length > 0
71
+ }
72
+ }
73
+
74
+ /**
75
+ * The frame the guidance arrives in.
76
+ *
77
+ * Labelled because the model is being handed text from a party other than the
78
+ * tool whose result it is reading, in that tool's slot. Unlabelled, it reads
79
+ * as something the tool said — so a steer saying "stop and ask me first" would
80
+ * look like output from `bash`.
81
+ *
82
+ * This is NOT the untrusted-content envelope. The host operating the run is
83
+ * the one party whose words the agent SHOULD act on; framing them as material
84
+ * to be worked with rather than followed would inverting the very thing the
85
+ * host is trying to do. Different party, different frame, on purpose.
86
+ */
87
+ export function formatSteeringNote(text: string): string {
88
+ return `\n\n[Message from the operator, received while this tool was running]\n${text}`
89
+ }
90
+
91
+ /**
92
+ * Append the guidance to the last tool result in a settled batch.
93
+ *
94
+ * The LAST one, so it is the final thing the model reads before deciding what
95
+ * to do next — appending to the first would bury it under every later result.
96
+ *
97
+ * Returns the messages unchanged when there is nothing queued, and when the
98
+ * batch carries no tool result to attach to. The second case is not a failure
99
+ * to handle: a turn that called no tools has nothing in flight, so guidance
100
+ * belongs to the next turn and stays queued for it.
101
+ */
102
+ export function attachSteering(
103
+ messages: readonly Message[],
104
+ channel: SteeringChannel | undefined,
105
+ ): readonly Message[] {
106
+ if (!channel?.pending) return messages
107
+
108
+ let lastToolIndex = -1
109
+ for (let index = messages.length - 1; index >= 0; index--) {
110
+ if (messages[index]?.role === 'tool') {
111
+ lastToolIndex = index
112
+ break
113
+ }
114
+ }
115
+ if (lastToolIndex === -1) return messages
116
+
117
+ const guidance = channel.drain()
118
+ if (guidance === undefined) return messages
119
+
120
+ const target = messages[lastToolIndex] as Message
121
+ // Only text is extended. A tool that answered with structured content —
122
+ // an image block, say — has a shape the model reads positionally, and
123
+ // appending a string to it would either be dropped or corrupt the block.
124
+ // Such a result keeps its content and the note follows it as its own text
125
+ // part where the shape allows, and otherwise the guidance stays queued
126
+ // for the next turn rather than being forced into a slot it does not fit.
127
+ if (typeof target.content !== 'string') {
128
+ channel.steer(guidance)
129
+ return messages
130
+ }
131
+
132
+ const next = [...messages]
133
+ next[lastToolIndex] = { ...target, content: target.content + formatSteeringNote(guidance) }
134
+ return next
135
+ }