world-model-optimizer 0.2.0__py3-none-any.whl

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 (308) hide show
  1. llm_waterfall/LICENSE +21 -0
  2. llm_waterfall/__init__.py +53 -0
  3. llm_waterfall/adapters/__init__.py +36 -0
  4. llm_waterfall/adapters/anthropic.py +105 -0
  5. llm_waterfall/adapters/aws_mantle.py +47 -0
  6. llm_waterfall/adapters/azure_openai.py +71 -0
  7. llm_waterfall/adapters/base.py +51 -0
  8. llm_waterfall/adapters/bedrock.py +309 -0
  9. llm_waterfall/adapters/openai.py +130 -0
  10. llm_waterfall/classify.py +184 -0
  11. llm_waterfall/pricing.py +110 -0
  12. llm_waterfall/py.typed +0 -0
  13. llm_waterfall/types.py +295 -0
  14. llm_waterfall/waterfall.py +255 -0
  15. wmo/__init__.py +38 -0
  16. wmo/agents/__init__.py +7 -0
  17. wmo/agents/default.py +29 -0
  18. wmo/agents/meta.py +55 -0
  19. wmo/agents/optimizer.py +55 -0
  20. wmo/agents/project.py +928 -0
  21. wmo/cli/__init__.py +5 -0
  22. wmo/cli/agent_session.py +1123 -0
  23. wmo/cli/app.py +2489 -0
  24. wmo/cli/e2b_cmds.py +212 -0
  25. wmo/cli/eval_closed_loop.py +207 -0
  26. wmo/cli/harness_app.py +1147 -0
  27. wmo/cli/harness_distill.py +659 -0
  28. wmo/cli/hosted_session.py +880 -0
  29. wmo/cli/ingest_cmd.py +165 -0
  30. wmo/cli/model_roles.py +82 -0
  31. wmo/cli/platform_cmds.py +372 -0
  32. wmo/cli/route_app.py +274 -0
  33. wmo/cli/session_state.py +243 -0
  34. wmo/cli/ui.py +1107 -0
  35. wmo/cli/workspace_sync.py +504 -0
  36. wmo/config/__init__.py +60 -0
  37. wmo/config/card.py +129 -0
  38. wmo/config/config.py +367 -0
  39. wmo/config/dotenv.py +67 -0
  40. wmo/config/settings.py +128 -0
  41. wmo/config/store.py +177 -0
  42. wmo/conftest.py +19 -0
  43. wmo/connect/__init__.py +88 -0
  44. wmo/connect/apps.py +78 -0
  45. wmo/connect/brave.py +284 -0
  46. wmo/connect/connector.py +79 -0
  47. wmo/connect/credentials.py +164 -0
  48. wmo/connect/github.py +321 -0
  49. wmo/connect/google.py +627 -0
  50. wmo/connect/notion.py +790 -0
  51. wmo/connect/oauth.py +461 -0
  52. wmo/connect/slack.py +555 -0
  53. wmo/connect/store.py +199 -0
  54. wmo/connect/types.py +156 -0
  55. wmo/core/__init__.py +21 -0
  56. wmo/core/parsing.py +281 -0
  57. wmo/core/render.py +271 -0
  58. wmo/core/text.py +40 -0
  59. wmo/core/types.py +116 -0
  60. wmo/distill/__init__.py +14 -0
  61. wmo/distill/agents.py +140 -0
  62. wmo/distill/config.py +1006 -0
  63. wmo/distill/cost.py +437 -0
  64. wmo/distill/data.py +921 -0
  65. wmo/distill/deadlines.py +254 -0
  66. wmo/distill/fake_tinker.py +734 -0
  67. wmo/distill/gate.py +122 -0
  68. wmo/distill/loop.py +3499 -0
  69. wmo/distill/renderers.py +399 -0
  70. wmo/distill/rendering.py +620 -0
  71. wmo/distill/rollouts.py +726 -0
  72. wmo/distill/samples.py +195 -0
  73. wmo/distill/store.py +829 -0
  74. wmo/distill/teacher.py +714 -0
  75. wmo/distill/tokens.py +535 -0
  76. wmo/distill/tracking.py +552 -0
  77. wmo/distill/tripwire.py +411 -0
  78. wmo/distill/xtoken/byte_offsets.py +152 -0
  79. wmo/distill/xtoken/chunks.py +457 -0
  80. wmo/distill/xtoken/prompt_logprobs.py +475 -0
  81. wmo/distill/xtoken/teacher_render.py +346 -0
  82. wmo/engine/__init__.py +28 -0
  83. wmo/engine/autoconfig.py +367 -0
  84. wmo/engine/build.py +346 -0
  85. wmo/engine/demo.py +77 -0
  86. wmo/engine/eval_suites.py +245 -0
  87. wmo/engine/grounding.py +491 -0
  88. wmo/engine/knowledge.py +291 -0
  89. wmo/engine/loader.py +36 -0
  90. wmo/engine/play.py +92 -0
  91. wmo/engine/prompts.py +99 -0
  92. wmo/engine/replay.py +443 -0
  93. wmo/engine/reporting.py +58 -0
  94. wmo/engine/workspace.py +468 -0
  95. wmo/engine/world_model.py +568 -0
  96. wmo/env/__init__.py +22 -0
  97. wmo/env/base.py +121 -0
  98. wmo/env/closed_loop.py +229 -0
  99. wmo/env/episode.py +107 -0
  100. wmo/env/llm_agent.py +93 -0
  101. wmo/env/scenarios.py +73 -0
  102. wmo/evals/__init__.py +52 -0
  103. wmo/evals/agreement.py +110 -0
  104. wmo/evals/base.py +45 -0
  105. wmo/evals/closed_loop.py +480 -0
  106. wmo/evals/failover.py +96 -0
  107. wmo/evals/gold.py +127 -0
  108. wmo/evals/grid.py +394 -0
  109. wmo/evals/grid_plot.py +205 -0
  110. wmo/evals/harbor/__init__.py +27 -0
  111. wmo/evals/harbor/agent.py +573 -0
  112. wmo/evals/harbor/ctrf.py +171 -0
  113. wmo/evals/harbor/e2b_environment.py +587 -0
  114. wmo/evals/harbor/e2b_template_policy.py +144 -0
  115. wmo/evals/harbor/scorer.py +875 -0
  116. wmo/evals/harbor/tasks.py +140 -0
  117. wmo/evals/open_loop.py +194 -0
  118. wmo/evals/tasks.py +53 -0
  119. wmo/harness/__init__.py +51 -0
  120. wmo/harness/code_runtime.py +288 -0
  121. wmo/harness/create.py +1191 -0
  122. wmo/harness/delta.py +220 -0
  123. wmo/harness/doc.py +556 -0
  124. wmo/harness/e2b_ledger.py +342 -0
  125. wmo/harness/e2b_reap.py +476 -0
  126. wmo/harness/e2b_sandbox.py +350 -0
  127. wmo/harness/environment.py +35 -0
  128. wmo/harness/live_session.py +543 -0
  129. wmo/harness/mutate.py +343 -0
  130. wmo/harness/pi_e2b.py +1710 -0
  131. wmo/harness/pi_entry/entry.ts +268 -0
  132. wmo/harness/pi_entry/runner_frames.ts +92 -0
  133. wmo/harness/pi_entry/runner_live.ts +587 -0
  134. wmo/harness/pi_entry/runner_service.ts +270 -0
  135. wmo/harness/pi_entry/runner_stdio.ts +374 -0
  136. wmo/harness/pi_entry/runner_termination.ts +142 -0
  137. wmo/harness/pi_local.py +262 -0
  138. wmo/harness/pi_runtime.py +495 -0
  139. wmo/harness/pi_vendor.py +65 -0
  140. wmo/harness/population.py +509 -0
  141. wmo/harness/project_proposer.py +569 -0
  142. wmo/harness/proposer.py +977 -0
  143. wmo/harness/runner_link.py +619 -0
  144. wmo/harness/runtime.py +389 -0
  145. wmo/harness/scoring.py +247 -0
  146. wmo/harness/skills.py +116 -0
  147. wmo/harness/source_tree.py +319 -0
  148. wmo/harness/store.py +176 -0
  149. wmo/harness/tools.py +105 -0
  150. wmo/harness/vendor/manifest.sha256 +58 -0
  151. wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
  152. wmo/harness/vendor/pi-agent/LICENSE +21 -0
  153. wmo/harness/vendor/pi-agent/README.md +488 -0
  154. wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
  155. wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
  156. wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
  157. wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
  158. wmo/harness/vendor/pi-agent/docs/models.md +966 -0
  159. wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
  160. wmo/harness/vendor/pi-agent/package.json +60 -0
  161. wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
  162. wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
  163. wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
  164. wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
  165. wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
  166. wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
  167. wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
  168. wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
  169. wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
  170. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
  171. wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
  172. wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
  173. wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
  174. wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
  175. wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
  176. wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
  177. wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
  178. wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
  179. wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
  180. wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
  181. wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
  182. wmo/harness/vendor/pi-agent/src/index.ts +44 -0
  183. wmo/harness/vendor/pi-agent/src/node.ts +2 -0
  184. wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
  185. wmo/harness/vendor/pi-agent/src/types.ts +428 -0
  186. wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
  187. wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
  188. wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
  189. wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
  190. wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
  191. wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
  192. wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
  193. wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
  194. wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
  195. wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
  196. wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
  197. wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
  198. wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
  199. wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
  200. wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
  201. wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
  202. wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
  203. wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
  204. wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
  205. wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
  206. wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
  207. wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
  208. wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
  209. wmo/harness/vendor/vendor_pi.sh +59 -0
  210. wmo/harness/workspace_patch.py +270 -0
  211. wmo/ingest/__init__.py +47 -0
  212. wmo/ingest/adapter.py +72 -0
  213. wmo/ingest/base.py +114 -0
  214. wmo/ingest/braintrust.py +339 -0
  215. wmo/ingest/detect.py +126 -0
  216. wmo/ingest/langfuse.py +291 -0
  217. wmo/ingest/langsmith.py +444 -0
  218. wmo/ingest/mastra.py +330 -0
  219. wmo/ingest/messages.py +170 -0
  220. wmo/ingest/normalize.py +679 -0
  221. wmo/ingest/otel_genai.py +69 -0
  222. wmo/ingest/otel_writer.py +100 -0
  223. wmo/ingest/phoenix.py +150 -0
  224. wmo/ingest/postgres.py +246 -0
  225. wmo/ingest/posthog.py +320 -0
  226. wmo/ingest/quality.py +28 -0
  227. wmo/ingest/stream.py +209 -0
  228. wmo/ingest/testdata/sample_otlp.json +60 -0
  229. wmo/ingest/testdata/sample_spans.jsonl +3 -0
  230. wmo/optimize/__init__.py +25 -0
  231. wmo/optimize/base.py +143 -0
  232. wmo/optimize/gepa.py +806 -0
  233. wmo/optimize/judge.py +262 -0
  234. wmo/optimize/judge_quality.py +359 -0
  235. wmo/optimize/knn.py +468 -0
  236. wmo/optimize/numeric.py +152 -0
  237. wmo/optimize/outcomes.py +103 -0
  238. wmo/optimize/policy.py +669 -0
  239. wmo/optimize/report.py +231 -0
  240. wmo/optimize/reward.py +129 -0
  241. wmo/optimize/routing.py +373 -0
  242. wmo/platform/__init__.py +6 -0
  243. wmo/platform/auth.py +115 -0
  244. wmo/platform/client.py +551 -0
  245. wmo/platform/credentials.py +126 -0
  246. wmo/platform/transfer.py +158 -0
  247. wmo/providers/__init__.py +40 -0
  248. wmo/providers/_bedrock_chat.py +155 -0
  249. wmo/providers/_openai_common.py +182 -0
  250. wmo/providers/_responses_common.py +472 -0
  251. wmo/providers/anthropic.py +134 -0
  252. wmo/providers/azure_openai.py +296 -0
  253. wmo/providers/base.py +300 -0
  254. wmo/providers/bedrock.py +312 -0
  255. wmo/providers/models.py +205 -0
  256. wmo/providers/openai.py +143 -0
  257. wmo/providers/openai_responses.py +240 -0
  258. wmo/providers/pool.py +170 -0
  259. wmo/providers/registry.py +73 -0
  260. wmo/providers/retry.py +151 -0
  261. wmo/providers/tinker.py +936 -0
  262. wmo/providers/waterfall.py +336 -0
  263. wmo/research/__init__.py +81 -0
  264. wmo/research/ablation.py +133 -0
  265. wmo/research/concurrency_plot.py +523 -0
  266. wmo/research/concurrency_run.py +240 -0
  267. wmo/research/concurrency_scaling.py +270 -0
  268. wmo/research/gepa_scaling.py +274 -0
  269. wmo/research/pipeline.py +198 -0
  270. wmo/research/scaling_split.py +82 -0
  271. wmo/research/scenario_fidelity.py +198 -0
  272. wmo/research/scenario_recovery.py +92 -0
  273. wmo/research/seed_stability.py +90 -0
  274. wmo/research/trace_scaling.py +348 -0
  275. wmo/retrieval/__init__.py +6 -0
  276. wmo/retrieval/embedders.py +105 -0
  277. wmo/retrieval/leakfree.py +52 -0
  278. wmo/retrieval/retriever.py +173 -0
  279. wmo/scenarios/__init__.py +58 -0
  280. wmo/scenarios/builder.py +152 -0
  281. wmo/scenarios/mining/__init__.py +27 -0
  282. wmo/scenarios/mining/clustering.py +171 -0
  283. wmo/scenarios/mining/facets.py +226 -0
  284. wmo/scenarios/mining/selection.py +220 -0
  285. wmo/scenarios/synthesis/__init__.py +6 -0
  286. wmo/scenarios/synthesis/scenario_set.py +63 -0
  287. wmo/scenarios/synthesis/synthesizer.py +85 -0
  288. wmo/scenarios/verification/__init__.py +17 -0
  289. wmo/scenarios/verification/judge.py +97 -0
  290. wmo/scenarios/verification/verify.py +135 -0
  291. wmo/serving/__init__.py +5 -0
  292. wmo/serving/builds.py +451 -0
  293. wmo/serving/chat.py +878 -0
  294. wmo/serving/endpoint_config.py +64 -0
  295. wmo/serving/savings.py +250 -0
  296. wmo/serving/server.py +553 -0
  297. wmo/serving/traces_source.py +206 -0
  298. wmo/telemetry.py +213 -0
  299. wmo/tracking/__init__.py +36 -0
  300. wmo/tracking/clock.py +24 -0
  301. wmo/tracking/metered.py +125 -0
  302. wmo/tracking/pricing.py +99 -0
  303. wmo/tracking/store.py +31 -0
  304. wmo/tracking/tracker.py +149 -0
  305. world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
  306. world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
  307. world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
  308. world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,1351 @@
1
+ import {
2
+ type AssistantMessage,
3
+ type AssistantMessageEvent,
4
+ EventStream,
5
+ type Message,
6
+ type Model,
7
+ type UserMessage,
8
+ } from "@earendil-works/pi-ai";
9
+ import { Type } from "typebox";
10
+ import { describe, expect, it } from "vitest";
11
+ import { agentLoop, agentLoopContinue } from "../src/agent-loop.ts";
12
+ import type { AgentContext, AgentEvent, AgentLoopConfig, AgentMessage, AgentTool } from "../src/types.ts";
13
+
14
+ // Mock stream for testing - mimics MockAssistantStream
15
+ class MockAssistantStream extends EventStream<AssistantMessageEvent, AssistantMessage> {
16
+ constructor() {
17
+ super(
18
+ (event) => event.type === "done" || event.type === "error",
19
+ (event) => {
20
+ if (event.type === "done") return event.message;
21
+ if (event.type === "error") return event.error;
22
+ throw new Error("Unexpected event type");
23
+ },
24
+ );
25
+ }
26
+ }
27
+
28
+ function createUsage() {
29
+ return {
30
+ input: 0,
31
+ output: 0,
32
+ cacheRead: 0,
33
+ cacheWrite: 0,
34
+ totalTokens: 0,
35
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
36
+ };
37
+ }
38
+
39
+ function createModel(): Model<"openai-responses"> {
40
+ return {
41
+ id: "mock",
42
+ name: "mock",
43
+ api: "openai-responses",
44
+ provider: "openai",
45
+ baseUrl: "https://example.invalid",
46
+ reasoning: false,
47
+ input: ["text"],
48
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
49
+ contextWindow: 8192,
50
+ maxTokens: 2048,
51
+ };
52
+ }
53
+
54
+ function createAssistantMessage(
55
+ content: AssistantMessage["content"],
56
+ stopReason: AssistantMessage["stopReason"] = "stop",
57
+ ): AssistantMessage {
58
+ return {
59
+ role: "assistant",
60
+ content,
61
+ api: "openai-responses",
62
+ provider: "openai",
63
+ model: "mock",
64
+ usage: createUsage(),
65
+ stopReason,
66
+ timestamp: Date.now(),
67
+ };
68
+ }
69
+
70
+ function createUserMessage(text: string): UserMessage {
71
+ return {
72
+ role: "user",
73
+ content: text,
74
+ timestamp: Date.now(),
75
+ };
76
+ }
77
+
78
+ // Simple identity converter for tests - just passes through standard messages
79
+ function identityConverter(messages: AgentMessage[]): Message[] {
80
+ return messages.filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
81
+ }
82
+
83
+ describe("agentLoop with AgentMessage", () => {
84
+ it("should emit events with AgentMessage types", async () => {
85
+ const context: AgentContext = {
86
+ systemPrompt: "You are helpful.",
87
+ messages: [],
88
+ tools: [],
89
+ };
90
+
91
+ const userPrompt: AgentMessage = createUserMessage("Hello");
92
+
93
+ const config: AgentLoopConfig = {
94
+ model: createModel(),
95
+ convertToLlm: identityConverter,
96
+ };
97
+
98
+ const streamFn = () => {
99
+ const stream = new MockAssistantStream();
100
+ queueMicrotask(() => {
101
+ const message = createAssistantMessage([{ type: "text", text: "Hi there!" }]);
102
+ stream.push({ type: "done", reason: "stop", message });
103
+ });
104
+ return stream;
105
+ };
106
+
107
+ const events: AgentEvent[] = [];
108
+ const stream = agentLoop([userPrompt], context, config, undefined, streamFn);
109
+
110
+ for await (const event of stream) {
111
+ events.push(event);
112
+ }
113
+
114
+ const messages = await stream.result();
115
+
116
+ // Should have user message and assistant message
117
+ expect(messages.length).toBe(2);
118
+ expect(messages[0].role).toBe("user");
119
+ expect(messages[1].role).toBe("assistant");
120
+
121
+ // Verify event sequence
122
+ const eventTypes = events.map((e) => e.type);
123
+ expect(eventTypes).toContain("agent_start");
124
+ expect(eventTypes).toContain("turn_start");
125
+ expect(eventTypes).toContain("message_start");
126
+ expect(eventTypes).toContain("message_end");
127
+ expect(eventTypes).toContain("turn_end");
128
+ expect(eventTypes).toContain("agent_end");
129
+ });
130
+
131
+ it("should handle custom message types via convertToLlm", async () => {
132
+ // Create a custom message type
133
+ interface CustomNotification {
134
+ role: "notification";
135
+ text: string;
136
+ timestamp: number;
137
+ }
138
+
139
+ const notification: CustomNotification = {
140
+ role: "notification",
141
+ text: "This is a notification",
142
+ timestamp: Date.now(),
143
+ };
144
+
145
+ const context: AgentContext = {
146
+ systemPrompt: "You are helpful.",
147
+ messages: [notification as unknown as AgentMessage], // Custom message in context
148
+ tools: [],
149
+ };
150
+
151
+ const userPrompt: AgentMessage = createUserMessage("Hello");
152
+
153
+ let convertedMessages: Message[] = [];
154
+ const config: AgentLoopConfig = {
155
+ model: createModel(),
156
+ convertToLlm: (messages) => {
157
+ // Filter out notifications, convert rest
158
+ convertedMessages = messages
159
+ .filter((m) => (m as { role: string }).role !== "notification")
160
+ .filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
161
+ return convertedMessages;
162
+ },
163
+ };
164
+
165
+ const streamFn = () => {
166
+ const stream = new MockAssistantStream();
167
+ queueMicrotask(() => {
168
+ const message = createAssistantMessage([{ type: "text", text: "Response" }]);
169
+ stream.push({ type: "done", reason: "stop", message });
170
+ });
171
+ return stream;
172
+ };
173
+
174
+ const events: AgentEvent[] = [];
175
+ const stream = agentLoop([userPrompt], context, config, undefined, streamFn);
176
+
177
+ for await (const event of stream) {
178
+ events.push(event);
179
+ }
180
+
181
+ // The notification should have been filtered out in convertToLlm
182
+ expect(convertedMessages.length).toBe(1); // Only user message
183
+ expect(convertedMessages[0].role).toBe("user");
184
+ });
185
+
186
+ it("should apply transformContext before convertToLlm", async () => {
187
+ const context: AgentContext = {
188
+ systemPrompt: "You are helpful.",
189
+ messages: [
190
+ createUserMessage("old message 1"),
191
+ createAssistantMessage([{ type: "text", text: "old response 1" }]),
192
+ createUserMessage("old message 2"),
193
+ createAssistantMessage([{ type: "text", text: "old response 2" }]),
194
+ ],
195
+ tools: [],
196
+ };
197
+
198
+ const userPrompt: AgentMessage = createUserMessage("new message");
199
+
200
+ let transformedMessages: AgentMessage[] = [];
201
+ let convertedMessages: Message[] = [];
202
+
203
+ const config: AgentLoopConfig = {
204
+ model: createModel(),
205
+ transformContext: async (messages) => {
206
+ // Keep only last 2 messages (prune old ones)
207
+ transformedMessages = messages.slice(-2);
208
+ return transformedMessages;
209
+ },
210
+ convertToLlm: (messages) => {
211
+ convertedMessages = messages.filter(
212
+ (m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult",
213
+ ) as Message[];
214
+ return convertedMessages;
215
+ },
216
+ };
217
+
218
+ const streamFn = () => {
219
+ const stream = new MockAssistantStream();
220
+ queueMicrotask(() => {
221
+ const message = createAssistantMessage([{ type: "text", text: "Response" }]);
222
+ stream.push({ type: "done", reason: "stop", message });
223
+ });
224
+ return stream;
225
+ };
226
+
227
+ const stream = agentLoop([userPrompt], context, config, undefined, streamFn);
228
+
229
+ for await (const _ of stream) {
230
+ // consume
231
+ }
232
+
233
+ // transformContext should have been called first, keeping only last 2
234
+ expect(transformedMessages.length).toBe(2);
235
+ // Then convertToLlm receives the pruned messages
236
+ expect(convertedMessages.length).toBe(2);
237
+ });
238
+
239
+ it("should handle tool calls and results", async () => {
240
+ const toolSchema = Type.Object({ value: Type.String() });
241
+ const executed: string[] = [];
242
+ const tool: AgentTool<typeof toolSchema, { value: string }> = {
243
+ name: "echo",
244
+ label: "Echo",
245
+ description: "Echo tool",
246
+ parameters: toolSchema,
247
+ async execute(_toolCallId, params) {
248
+ executed.push(params.value);
249
+ return {
250
+ content: [{ type: "text", text: `echoed: ${params.value}` }],
251
+ details: { value: params.value },
252
+ };
253
+ },
254
+ };
255
+
256
+ const context: AgentContext = {
257
+ systemPrompt: "",
258
+ messages: [],
259
+ tools: [tool],
260
+ };
261
+
262
+ const userPrompt: AgentMessage = createUserMessage("echo something");
263
+
264
+ const config: AgentLoopConfig = {
265
+ model: createModel(),
266
+ convertToLlm: identityConverter,
267
+ };
268
+
269
+ let callIndex = 0;
270
+ const streamFn = () => {
271
+ const stream = new MockAssistantStream();
272
+ queueMicrotask(() => {
273
+ if (callIndex === 0) {
274
+ // First call: return tool call
275
+ const message = createAssistantMessage(
276
+ [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }],
277
+ "toolUse",
278
+ );
279
+ stream.push({ type: "done", reason: "toolUse", message });
280
+ } else {
281
+ // Second call: return final response
282
+ const message = createAssistantMessage([{ type: "text", text: "done" }]);
283
+ stream.push({ type: "done", reason: "stop", message });
284
+ }
285
+ callIndex++;
286
+ });
287
+ return stream;
288
+ };
289
+
290
+ const events: AgentEvent[] = [];
291
+ const stream = agentLoop([userPrompt], context, config, undefined, streamFn);
292
+
293
+ for await (const event of stream) {
294
+ events.push(event);
295
+ }
296
+
297
+ // Tool should have been executed
298
+ expect(executed).toEqual(["hello"]);
299
+
300
+ // Should have tool execution events
301
+ const toolStart = events.find((e) => e.type === "tool_execution_start");
302
+ const toolEnd = events.find((e) => e.type === "tool_execution_end");
303
+ expect(toolStart).toBeDefined();
304
+ expect(toolEnd).toBeDefined();
305
+ if (toolEnd?.type === "tool_execution_end") {
306
+ expect(toolEnd.isError).toBe(false);
307
+ }
308
+ });
309
+
310
+ it("should execute mutated beforeToolCall args without revalidation", async () => {
311
+ const toolSchema = Type.Object({ value: Type.String() });
312
+ const executed: Array<string | number> = [];
313
+ const tool: AgentTool<typeof toolSchema, { value: string | number }> = {
314
+ name: "echo",
315
+ label: "Echo",
316
+ description: "Echo tool",
317
+ parameters: toolSchema,
318
+ async execute(_toolCallId, params) {
319
+ executed.push(params.value as string | number);
320
+ return {
321
+ content: [{ type: "text", text: `echoed: ${String(params.value)}` }],
322
+ details: { value: params.value as string | number },
323
+ };
324
+ },
325
+ };
326
+
327
+ const context: AgentContext = {
328
+ systemPrompt: "",
329
+ messages: [],
330
+ tools: [tool],
331
+ };
332
+
333
+ const userPrompt: AgentMessage = createUserMessage("echo something");
334
+
335
+ const config: AgentLoopConfig = {
336
+ model: createModel(),
337
+ convertToLlm: identityConverter,
338
+ beforeToolCall: async ({ args }) => {
339
+ const mutableArgs = args as { value: string | number };
340
+ mutableArgs.value = 123;
341
+ return undefined;
342
+ },
343
+ };
344
+
345
+ let callIndex = 0;
346
+ const streamFn = () => {
347
+ const stream = new MockAssistantStream();
348
+ queueMicrotask(() => {
349
+ if (callIndex === 0) {
350
+ const message = createAssistantMessage(
351
+ [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }],
352
+ "toolUse",
353
+ );
354
+ stream.push({ type: "done", reason: "toolUse", message });
355
+ } else {
356
+ const message = createAssistantMessage([{ type: "text", text: "done" }]);
357
+ stream.push({ type: "done", reason: "stop", message });
358
+ }
359
+ callIndex++;
360
+ });
361
+ return stream;
362
+ };
363
+
364
+ const stream = agentLoop([userPrompt], context, config, undefined, streamFn);
365
+ for await (const _event of stream) {
366
+ // consume
367
+ }
368
+
369
+ expect(executed).toEqual([123]);
370
+ });
371
+
372
+ it("should prepare tool arguments for validation", async () => {
373
+ const replaceSchema = Type.Object({ oldText: Type.String(), newText: Type.String() });
374
+ const toolSchema = Type.Object({ edits: Type.Array(replaceSchema) });
375
+ const executed: Array<Array<{ oldText: string; newText: string }>> = [];
376
+ const tool: AgentTool<typeof toolSchema, { count: number }> = {
377
+ name: "edit",
378
+ label: "Edit",
379
+ description: "Edit tool",
380
+ parameters: toolSchema,
381
+ prepareArguments(args) {
382
+ if (!args || typeof args !== "object") {
383
+ return args as { edits: { oldText: string; newText: string }[] };
384
+ }
385
+ const input = args as {
386
+ edits?: Array<{ oldText: string; newText: string }>;
387
+ oldText?: string;
388
+ newText?: string;
389
+ };
390
+ if (typeof input.oldText !== "string" || typeof input.newText !== "string") {
391
+ return args as { edits: { oldText: string; newText: string }[] };
392
+ }
393
+ return {
394
+ edits: [...(input.edits ?? []), { oldText: input.oldText, newText: input.newText }],
395
+ };
396
+ },
397
+ async execute(_toolCallId, params) {
398
+ executed.push(params.edits);
399
+ return {
400
+ content: [{ type: "text", text: `edited ${params.edits.length}` }],
401
+ details: { count: params.edits.length },
402
+ };
403
+ },
404
+ };
405
+
406
+ const context: AgentContext = {
407
+ systemPrompt: "",
408
+ messages: [],
409
+ tools: [tool],
410
+ };
411
+
412
+ const userPrompt: AgentMessage = createUserMessage("edit something");
413
+ const config: AgentLoopConfig = {
414
+ model: createModel(),
415
+ convertToLlm: identityConverter,
416
+ };
417
+
418
+ let callIndex = 0;
419
+ const streamFn = () => {
420
+ const stream = new MockAssistantStream();
421
+ queueMicrotask(() => {
422
+ if (callIndex === 0) {
423
+ const message = createAssistantMessage(
424
+ [
425
+ {
426
+ type: "toolCall",
427
+ id: "tool-1",
428
+ name: "edit",
429
+ arguments: { oldText: "before", newText: "after" },
430
+ },
431
+ ],
432
+ "toolUse",
433
+ );
434
+ stream.push({ type: "done", reason: "toolUse", message });
435
+ } else {
436
+ const message = createAssistantMessage([{ type: "text", text: "done" }]);
437
+ stream.push({ type: "done", reason: "stop", message });
438
+ }
439
+ callIndex++;
440
+ });
441
+ return stream;
442
+ };
443
+
444
+ const stream = agentLoop([userPrompt], context, config, undefined, streamFn);
445
+ for await (const _event of stream) {
446
+ // consume
447
+ }
448
+
449
+ expect(executed).toEqual([[{ oldText: "before", newText: "after" }]]);
450
+ });
451
+
452
+ it("should emit tool_execution_end in completion order but persist tool results in source order", async () => {
453
+ const toolSchema = Type.Object({ value: Type.String() });
454
+ let firstResolved = false;
455
+ let parallelObserved = false;
456
+ let releaseFirst: (() => void) | undefined;
457
+ const firstDone = new Promise<void>((resolve) => {
458
+ releaseFirst = resolve;
459
+ });
460
+
461
+ const tool: AgentTool<typeof toolSchema, { value: string }> = {
462
+ name: "echo",
463
+ label: "Echo",
464
+ description: "Echo tool",
465
+ parameters: toolSchema,
466
+ async execute(_toolCallId, params) {
467
+ if (params.value === "first") {
468
+ await firstDone;
469
+ firstResolved = true;
470
+ }
471
+ if (params.value === "second" && !firstResolved) {
472
+ parallelObserved = true;
473
+ }
474
+ return {
475
+ content: [{ type: "text", text: `echoed: ${params.value}` }],
476
+ details: { value: params.value },
477
+ };
478
+ },
479
+ };
480
+
481
+ const context: AgentContext = {
482
+ systemPrompt: "",
483
+ messages: [],
484
+ tools: [tool],
485
+ };
486
+
487
+ const userPrompt: AgentMessage = createUserMessage("echo both");
488
+ const config: AgentLoopConfig = {
489
+ model: createModel(),
490
+ convertToLlm: identityConverter,
491
+ toolExecution: "parallel",
492
+ };
493
+
494
+ let callIndex = 0;
495
+ const stream = agentLoop([userPrompt], context, config, undefined, () => {
496
+ const mockStream = new MockAssistantStream();
497
+ queueMicrotask(() => {
498
+ if (callIndex === 0) {
499
+ const message = createAssistantMessage(
500
+ [
501
+ { type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
502
+ { type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
503
+ ],
504
+ "toolUse",
505
+ );
506
+ mockStream.push({ type: "done", reason: "toolUse", message });
507
+ setTimeout(() => releaseFirst?.(), 20);
508
+ } else {
509
+ const message = createAssistantMessage([{ type: "text", text: "done" }]);
510
+ mockStream.push({ type: "done", reason: "stop", message });
511
+ }
512
+ callIndex++;
513
+ });
514
+ return mockStream;
515
+ });
516
+
517
+ const events: AgentEvent[] = [];
518
+ for await (const event of stream) {
519
+ events.push(event);
520
+ }
521
+
522
+ const toolExecutionEndIds = events.flatMap((event) => {
523
+ if (event.type !== "tool_execution_end") {
524
+ return [];
525
+ }
526
+ return [event.toolCallId];
527
+ });
528
+ const toolResultIds = events.flatMap((event) => {
529
+ if (event.type !== "message_end" || event.message.role !== "toolResult") {
530
+ return [];
531
+ }
532
+ return [event.message.toolCallId];
533
+ });
534
+ const turnToolResultIds = events.flatMap((event) => {
535
+ if (event.type !== "turn_end") {
536
+ return [];
537
+ }
538
+ return event.toolResults.map((toolResult) => toolResult.toolCallId);
539
+ });
540
+
541
+ expect(parallelObserved).toBe(true);
542
+ expect(toolExecutionEndIds).toEqual(["tool-2", "tool-1"]);
543
+ expect(toolResultIds).toEqual(["tool-1", "tool-2"]);
544
+ expect(turnToolResultIds).toEqual(["tool-1", "tool-2"]);
545
+ });
546
+
547
+ it("should inject queued messages after all tool calls complete", async () => {
548
+ const toolSchema = Type.Object({ value: Type.String() });
549
+ const executed: string[] = [];
550
+ const tool: AgentTool<typeof toolSchema, { value: string }> = {
551
+ name: "echo",
552
+ label: "Echo",
553
+ description: "Echo tool",
554
+ parameters: toolSchema,
555
+ async execute(_toolCallId, params) {
556
+ executed.push(params.value);
557
+ return {
558
+ content: [{ type: "text", text: `ok:${params.value}` }],
559
+ details: { value: params.value },
560
+ };
561
+ },
562
+ };
563
+
564
+ const context: AgentContext = {
565
+ systemPrompt: "",
566
+ messages: [],
567
+ tools: [tool],
568
+ };
569
+
570
+ const userPrompt: AgentMessage = createUserMessage("start");
571
+ const queuedUserMessage: AgentMessage = createUserMessage("interrupt");
572
+
573
+ let queuedDelivered = false;
574
+ let callIndex = 0;
575
+ let sawInterruptInContext = false;
576
+
577
+ const config: AgentLoopConfig = {
578
+ model: createModel(),
579
+ convertToLlm: identityConverter,
580
+ toolExecution: "sequential",
581
+ getSteeringMessages: async () => {
582
+ // Return steering message after tool execution has started.
583
+ if (executed.length >= 1 && !queuedDelivered) {
584
+ queuedDelivered = true;
585
+ return [queuedUserMessage];
586
+ }
587
+ return [];
588
+ },
589
+ };
590
+
591
+ const events: AgentEvent[] = [];
592
+ const stream = agentLoop([userPrompt], context, config, undefined, (_model, ctx, _options) => {
593
+ // Check if interrupt message is in context on second call
594
+ if (callIndex === 1) {
595
+ sawInterruptInContext = ctx.messages.some(
596
+ (m) => m.role === "user" && typeof m.content === "string" && m.content === "interrupt",
597
+ );
598
+ }
599
+
600
+ const mockStream = new MockAssistantStream();
601
+ queueMicrotask(() => {
602
+ if (callIndex === 0) {
603
+ // First call: return two tool calls
604
+ const message = createAssistantMessage(
605
+ [
606
+ { type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
607
+ { type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
608
+ ],
609
+ "toolUse",
610
+ );
611
+ mockStream.push({ type: "done", reason: "toolUse", message });
612
+ } else {
613
+ // Second call: return final response
614
+ const message = createAssistantMessage([{ type: "text", text: "done" }]);
615
+ mockStream.push({ type: "done", reason: "stop", message });
616
+ }
617
+ callIndex++;
618
+ });
619
+ return mockStream;
620
+ });
621
+
622
+ for await (const event of stream) {
623
+ events.push(event);
624
+ }
625
+
626
+ // Both tools should execute before steering is injected
627
+ expect(executed).toEqual(["first", "second"]);
628
+
629
+ const toolEnds = events.filter(
630
+ (e): e is Extract<AgentEvent, { type: "tool_execution_end" }> => e.type === "tool_execution_end",
631
+ );
632
+ expect(toolEnds.length).toBe(2);
633
+ expect(toolEnds[0].isError).toBe(false);
634
+ expect(toolEnds[1].isError).toBe(false);
635
+
636
+ // Queued message should appear in events after both tool result messages
637
+ const eventSequence = events.flatMap((event) => {
638
+ if (event.type !== "message_start") return [];
639
+ if (event.message.role === "toolResult") return [`tool:${event.message.toolCallId}`];
640
+ if (event.message.role === "user" && typeof event.message.content === "string") {
641
+ return [event.message.content];
642
+ }
643
+ return [];
644
+ });
645
+ expect(eventSequence).toContain("interrupt");
646
+ expect(eventSequence.indexOf("tool:tool-1")).toBeLessThan(eventSequence.indexOf("interrupt"));
647
+ expect(eventSequence.indexOf("tool:tool-2")).toBeLessThan(eventSequence.indexOf("interrupt"));
648
+
649
+ // Interrupt message should be in context when second LLM call is made
650
+ expect(sawInterruptInContext).toBe(true);
651
+ });
652
+
653
+ it("should force sequential execution when a tool has executionMode=sequential even with default parallel config", async () => {
654
+ const toolSchema = Type.Object({ value: Type.String() });
655
+ let firstResolved = false;
656
+ let parallelObserved = false;
657
+ let releaseFirst: (() => void) | undefined;
658
+ const firstDone = new Promise<void>((resolve) => {
659
+ releaseFirst = resolve;
660
+ });
661
+
662
+ const slowTool: AgentTool<typeof toolSchema, { value: string }> = {
663
+ name: "slow",
664
+ label: "Slow",
665
+ description: "Slow tool",
666
+ parameters: toolSchema,
667
+ executionMode: "sequential",
668
+ async execute(_toolCallId, params) {
669
+ if (params.value === "first") {
670
+ await firstDone;
671
+ firstResolved = true;
672
+ }
673
+ if (params.value === "second" && !firstResolved) {
674
+ parallelObserved = true;
675
+ }
676
+ return {
677
+ content: [{ type: "text", text: `slow: ${params.value}` }],
678
+ details: { value: params.value },
679
+ };
680
+ },
681
+ };
682
+
683
+ const context: AgentContext = {
684
+ systemPrompt: "",
685
+ messages: [],
686
+ tools: [slowTool],
687
+ };
688
+
689
+ const userPrompt: AgentMessage = createUserMessage("run both");
690
+ // config is parallel (default), but tool forces sequential
691
+ const config: AgentLoopConfig = {
692
+ model: createModel(),
693
+ convertToLlm: identityConverter,
694
+ };
695
+
696
+ let callIndex = 0;
697
+ const stream = agentLoop([userPrompt], context, config, undefined, () => {
698
+ const mockStream = new MockAssistantStream();
699
+ queueMicrotask(() => {
700
+ if (callIndex === 0) {
701
+ const message = createAssistantMessage(
702
+ [
703
+ { type: "toolCall", id: "tool-1", name: "slow", arguments: { value: "first" } },
704
+ { type: "toolCall", id: "tool-2", name: "slow", arguments: { value: "second" } },
705
+ ],
706
+ "toolUse",
707
+ );
708
+ mockStream.push({ type: "done", reason: "toolUse", message });
709
+ setTimeout(() => releaseFirst?.(), 20);
710
+ } else {
711
+ const message = createAssistantMessage([{ type: "text", text: "done" }]);
712
+ mockStream.push({ type: "done", reason: "stop", message });
713
+ }
714
+ callIndex++;
715
+ });
716
+ return mockStream;
717
+ });
718
+
719
+ const events: AgentEvent[] = [];
720
+ for await (const event of stream) {
721
+ events.push(event);
722
+ }
723
+
724
+ // With sequential execution, second tool should NOT start before first finishes
725
+ expect(parallelObserved).toBe(false);
726
+
727
+ const toolResultIds = events.flatMap((event) => {
728
+ if (event.type !== "message_end" || event.message.role !== "toolResult") {
729
+ return [];
730
+ }
731
+ return [event.message.toolCallId];
732
+ });
733
+ expect(toolResultIds).toEqual(["tool-1", "tool-2"]);
734
+ });
735
+
736
+ it("should force sequential execution when one of multiple tools has executionMode=sequential", async () => {
737
+ const toolSchema = Type.Object({ value: Type.String() });
738
+ const executionOrder: string[] = [];
739
+ let releaseSlow: (() => void) | undefined;
740
+ const slowDone = new Promise<void>((resolve) => {
741
+ releaseSlow = resolve;
742
+ });
743
+
744
+ const slowTool: AgentTool<typeof toolSchema, { value: string }> = {
745
+ name: "slow",
746
+ label: "Slow",
747
+ description: "Slow tool",
748
+ parameters: toolSchema,
749
+ executionMode: "sequential",
750
+ async execute(_toolCallId, params) {
751
+ executionOrder.push(`slow:${params.value}`);
752
+ if (params.value === "a") {
753
+ await slowDone;
754
+ }
755
+ return {
756
+ content: [{ type: "text", text: `slow: ${params.value}` }],
757
+ details: { value: params.value },
758
+ };
759
+ },
760
+ };
761
+
762
+ const fastTool: AgentTool<typeof toolSchema, { value: string }> = {
763
+ name: "fast",
764
+ label: "Fast",
765
+ description: "Fast tool",
766
+ parameters: toolSchema,
767
+ // no executionMode = defaults to parallel
768
+ async execute(_toolCallId, params) {
769
+ executionOrder.push(`fast:${params.value}`);
770
+ return {
771
+ content: [{ type: "text", text: `fast: ${params.value}` }],
772
+ details: { value: params.value },
773
+ };
774
+ },
775
+ };
776
+
777
+ const context: AgentContext = {
778
+ systemPrompt: "",
779
+ messages: [],
780
+ tools: [slowTool, fastTool],
781
+ };
782
+
783
+ const userPrompt: AgentMessage = createUserMessage("run both");
784
+ const config: AgentLoopConfig = {
785
+ model: createModel(),
786
+ convertToLlm: identityConverter,
787
+ // parallel by default, but slowTool forces sequential
788
+ };
789
+
790
+ let callIndex = 0;
791
+ const stream = agentLoop([userPrompt], context, config, undefined, () => {
792
+ const mockStream = new MockAssistantStream();
793
+ queueMicrotask(() => {
794
+ if (callIndex === 0) {
795
+ const message = createAssistantMessage(
796
+ [
797
+ { type: "toolCall", id: "tool-1", name: "slow", arguments: { value: "a" } },
798
+ { type: "toolCall", id: "tool-2", name: "fast", arguments: { value: "b" } },
799
+ ],
800
+ "toolUse",
801
+ );
802
+ mockStream.push({ type: "done", reason: "toolUse", message });
803
+ setTimeout(() => releaseSlow?.(), 20);
804
+ } else {
805
+ const message = createAssistantMessage([{ type: "text", text: "done" }]);
806
+ mockStream.push({ type: "done", reason: "stop", message });
807
+ }
808
+ callIndex++;
809
+ });
810
+ return mockStream;
811
+ });
812
+
813
+ const events: AgentEvent[] = [];
814
+ for await (const event of stream) {
815
+ events.push(event);
816
+ }
817
+
818
+ // Fast tool should NOT run before slow tool finishes
819
+ expect(executionOrder[0]).toBe("slow:a");
820
+ expect(executionOrder).toContain("fast:b");
821
+ });
822
+
823
+ it("should allow parallel execution when all tools have executionMode=parallel", async () => {
824
+ const toolSchema = Type.Object({ value: Type.String() });
825
+ let firstResolved = false;
826
+ let parallelObserved = false;
827
+ let releaseFirst: (() => void) | undefined;
828
+ const firstDone = new Promise<void>((resolve) => {
829
+ releaseFirst = resolve;
830
+ });
831
+
832
+ const tool: AgentTool<typeof toolSchema, { value: string }> = {
833
+ name: "echo",
834
+ label: "Echo",
835
+ description: "Echo tool",
836
+ parameters: toolSchema,
837
+ executionMode: "parallel",
838
+ async execute(_toolCallId, params) {
839
+ if (params.value === "first") {
840
+ await firstDone;
841
+ firstResolved = true;
842
+ }
843
+ if (params.value === "second" && !firstResolved) {
844
+ parallelObserved = true;
845
+ }
846
+ return {
847
+ content: [{ type: "text", text: `echoed: ${params.value}` }],
848
+ details: { value: params.value },
849
+ };
850
+ },
851
+ };
852
+
853
+ const context: AgentContext = {
854
+ systemPrompt: "",
855
+ messages: [],
856
+ tools: [tool],
857
+ };
858
+
859
+ const userPrompt: AgentMessage = createUserMessage("echo both");
860
+ const config: AgentLoopConfig = {
861
+ model: createModel(),
862
+ convertToLlm: identityConverter,
863
+ };
864
+
865
+ let callIndex = 0;
866
+ const stream = agentLoop([userPrompt], context, config, undefined, () => {
867
+ const mockStream = new MockAssistantStream();
868
+ queueMicrotask(() => {
869
+ if (callIndex === 0) {
870
+ const message = createAssistantMessage(
871
+ [
872
+ { type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
873
+ { type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
874
+ ],
875
+ "toolUse",
876
+ );
877
+ mockStream.push({ type: "done", reason: "toolUse", message });
878
+ setTimeout(() => releaseFirst?.(), 20);
879
+ } else {
880
+ const message = createAssistantMessage([{ type: "text", text: "done" }]);
881
+ mockStream.push({ type: "done", reason: "stop", message });
882
+ }
883
+ callIndex++;
884
+ });
885
+ return mockStream;
886
+ });
887
+
888
+ const events: AgentEvent[] = [];
889
+ for await (const event of stream) {
890
+ events.push(event);
891
+ }
892
+
893
+ // With executionMode=parallel, second tool should start before first finishes
894
+ expect(parallelObserved).toBe(true);
895
+ });
896
+
897
+ it("should use prepareNextTurn snapshot before continuing", async () => {
898
+ const toolSchema = Type.Object({ value: Type.String() });
899
+ const tool: AgentTool<typeof toolSchema, { value: string }> = {
900
+ name: "echo",
901
+ label: "Echo",
902
+ description: "Echo tool",
903
+ parameters: toolSchema,
904
+ async execute(_toolCallId, params) {
905
+ return {
906
+ content: [{ type: "text", text: `echoed: ${params.value}` }],
907
+ details: { value: params.value },
908
+ };
909
+ },
910
+ };
911
+ const context: AgentContext = {
912
+ systemPrompt: "first prompt",
913
+ messages: [],
914
+ tools: [tool],
915
+ };
916
+ let convertedSecondTurnSystemPrompt = "";
917
+ let prepared = false;
918
+ const config: AgentLoopConfig = {
919
+ model: createModel(),
920
+ convertToLlm: identityConverter,
921
+ prepareNextTurn: async ({ context: currentContext }) => {
922
+ if (prepared) return undefined;
923
+ prepared = true;
924
+ return {
925
+ context: {
926
+ systemPrompt: "second prompt",
927
+ messages: currentContext.messages.slice(),
928
+ tools: currentContext.tools,
929
+ },
930
+ };
931
+ },
932
+ };
933
+
934
+ let llmCalls = 0;
935
+ const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, (_model, ctx) => {
936
+ llmCalls++;
937
+ if (llmCalls === 2) {
938
+ convertedSecondTurnSystemPrompt = ctx.systemPrompt ?? "";
939
+ }
940
+ const mockStream = new MockAssistantStream();
941
+ queueMicrotask(() => {
942
+ if (llmCalls === 1) {
943
+ mockStream.push({
944
+ type: "done",
945
+ reason: "toolUse",
946
+ message: createAssistantMessage(
947
+ [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }],
948
+ "toolUse",
949
+ ),
950
+ });
951
+ } else {
952
+ mockStream.push({
953
+ type: "done",
954
+ reason: "stop",
955
+ message: createAssistantMessage([{ type: "text", text: "done" }]),
956
+ });
957
+ }
958
+ });
959
+ return mockStream;
960
+ });
961
+
962
+ for await (const _event of stream) {
963
+ // consume
964
+ }
965
+
966
+ expect(llmCalls).toBe(2);
967
+ expect(convertedSecondTurnSystemPrompt).toBe("second prompt");
968
+ });
969
+
970
+ it("should stop after the current turn when shouldStopAfterTurn returns true", async () => {
971
+ const toolSchema = Type.Object({ value: Type.String() });
972
+ const executed: string[] = [];
973
+ const tool: AgentTool<typeof toolSchema, { value: string }> = {
974
+ name: "echo",
975
+ label: "Echo",
976
+ description: "Echo tool",
977
+ parameters: toolSchema,
978
+ async execute(_toolCallId, params) {
979
+ executed.push(params.value);
980
+ return {
981
+ content: [{ type: "text", text: `echoed: ${params.value}` }],
982
+ details: { value: params.value },
983
+ };
984
+ },
985
+ };
986
+
987
+ const context: AgentContext = {
988
+ systemPrompt: "",
989
+ messages: [],
990
+ tools: [tool],
991
+ };
992
+
993
+ let steeringPolls = 0;
994
+ let followUpPolls = 0;
995
+ let callbackToolResultIds: string[] = [];
996
+ let callbackContextRoles: string[] = [];
997
+ const config: AgentLoopConfig = {
998
+ model: createModel(),
999
+ convertToLlm: identityConverter,
1000
+ getSteeringMessages: async () => {
1001
+ steeringPolls++;
1002
+ return [];
1003
+ },
1004
+ getFollowUpMessages: async () => {
1005
+ followUpPolls++;
1006
+ return [createUserMessage("follow up should stay queued")];
1007
+ },
1008
+ shouldStopAfterTurn: async ({ message, toolResults, context }) => {
1009
+ expect(message.role).toBe("assistant");
1010
+ callbackToolResultIds = toolResults.map((toolResult) => toolResult.toolCallId);
1011
+ callbackContextRoles = context.messages.map((contextMessage) => contextMessage.role);
1012
+ return true;
1013
+ },
1014
+ };
1015
+
1016
+ let llmCalls = 0;
1017
+ const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, () => {
1018
+ llmCalls++;
1019
+ const mockStream = new MockAssistantStream();
1020
+ queueMicrotask(() => {
1021
+ if (llmCalls === 1) {
1022
+ const message = createAssistantMessage(
1023
+ [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }],
1024
+ "toolUse",
1025
+ );
1026
+ mockStream.push({ type: "done", reason: "toolUse", message });
1027
+ } else {
1028
+ mockStream.push({
1029
+ type: "done",
1030
+ reason: "stop",
1031
+ message: createAssistantMessage([{ type: "text", text: "should not run" }]),
1032
+ });
1033
+ }
1034
+ });
1035
+ return mockStream;
1036
+ });
1037
+
1038
+ const events: AgentEvent[] = [];
1039
+ for await (const event of stream) {
1040
+ events.push(event);
1041
+ }
1042
+
1043
+ const messages = await stream.result();
1044
+ expect(llmCalls).toBe(1);
1045
+ expect(executed).toEqual(["hello"]);
1046
+ expect(steeringPolls).toBe(1);
1047
+ expect(followUpPolls).toBe(0);
1048
+ expect(callbackToolResultIds).toEqual(["tool-1"]);
1049
+ expect(callbackContextRoles).toEqual(["user", "assistant", "toolResult"]);
1050
+ expect(messages.map((message) => message.role)).toEqual(["user", "assistant", "toolResult"]);
1051
+ expect(events.map((event) => event.type)).toEqual([
1052
+ "agent_start",
1053
+ "turn_start",
1054
+ "message_start",
1055
+ "message_end",
1056
+ "message_start",
1057
+ "message_end",
1058
+ "tool_execution_start",
1059
+ "tool_execution_end",
1060
+ "message_start",
1061
+ "message_end",
1062
+ "turn_end",
1063
+ "agent_end",
1064
+ ]);
1065
+ });
1066
+
1067
+ it("should stop after a tool batch when every tool result sets terminate=true", async () => {
1068
+ const toolSchema = Type.Object({ value: Type.String() });
1069
+ const tool: AgentTool<typeof toolSchema, { value: string }> = {
1070
+ name: "echo",
1071
+ label: "Echo",
1072
+ description: "Echo tool",
1073
+ parameters: toolSchema,
1074
+ async execute(_toolCallId, params) {
1075
+ return {
1076
+ content: [{ type: "text", text: `echoed: ${params.value}` }],
1077
+ details: { value: params.value },
1078
+ terminate: true,
1079
+ };
1080
+ },
1081
+ };
1082
+
1083
+ const context: AgentContext = {
1084
+ systemPrompt: "",
1085
+ messages: [],
1086
+ tools: [tool],
1087
+ };
1088
+
1089
+ const config: AgentLoopConfig = {
1090
+ model: createModel(),
1091
+ convertToLlm: identityConverter,
1092
+ };
1093
+
1094
+ let llmCalls = 0;
1095
+ const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, () => {
1096
+ llmCalls++;
1097
+ const mockStream = new MockAssistantStream();
1098
+ queueMicrotask(() => {
1099
+ const message = createAssistantMessage(
1100
+ [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }],
1101
+ "toolUse",
1102
+ );
1103
+ mockStream.push({ type: "done", reason: "toolUse", message });
1104
+ });
1105
+ return mockStream;
1106
+ });
1107
+
1108
+ const events: AgentEvent[] = [];
1109
+ for await (const event of stream) {
1110
+ events.push(event);
1111
+ }
1112
+
1113
+ const messages = await stream.result();
1114
+ expect(llmCalls).toBe(1);
1115
+ expect(messages.map((message) => message.role)).toEqual(["user", "assistant", "toolResult"]);
1116
+ expect(events.filter((event) => event.type === "turn_end")).toHaveLength(1);
1117
+ });
1118
+
1119
+ it("should continue after parallel tool calls when not all tool results terminate", async () => {
1120
+ const toolSchema = Type.Object({ value: Type.String() });
1121
+ const tool: AgentTool<typeof toolSchema, { value: string }> = {
1122
+ name: "echo",
1123
+ label: "Echo",
1124
+ description: "Echo tool",
1125
+ parameters: toolSchema,
1126
+ async execute(_toolCallId, params) {
1127
+ return {
1128
+ content: [{ type: "text", text: `echoed: ${params.value}` }],
1129
+ details: { value: params.value },
1130
+ terminate: params.value === "first",
1131
+ };
1132
+ },
1133
+ };
1134
+
1135
+ const context: AgentContext = {
1136
+ systemPrompt: "",
1137
+ messages: [],
1138
+ tools: [tool],
1139
+ };
1140
+
1141
+ const config: AgentLoopConfig = {
1142
+ model: createModel(),
1143
+ convertToLlm: identityConverter,
1144
+ toolExecution: "parallel",
1145
+ };
1146
+
1147
+ let callIndex = 0;
1148
+ const stream = agentLoop([createUserMessage("echo both")], context, config, undefined, () => {
1149
+ const mockStream = new MockAssistantStream();
1150
+ queueMicrotask(() => {
1151
+ if (callIndex === 0) {
1152
+ const message = createAssistantMessage(
1153
+ [
1154
+ { type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "first" } },
1155
+ { type: "toolCall", id: "tool-2", name: "echo", arguments: { value: "second" } },
1156
+ ],
1157
+ "toolUse",
1158
+ );
1159
+ mockStream.push({ type: "done", reason: "toolUse", message });
1160
+ } else {
1161
+ const message = createAssistantMessage([{ type: "text", text: "done" }]);
1162
+ mockStream.push({ type: "done", reason: "stop", message });
1163
+ }
1164
+ callIndex++;
1165
+ });
1166
+ return mockStream;
1167
+ });
1168
+
1169
+ for await (const _event of stream) {
1170
+ // consume
1171
+ }
1172
+
1173
+ const messages = await stream.result();
1174
+ expect(callIndex).toBe(2);
1175
+ expect(messages.map((message) => message.role)).toEqual([
1176
+ "user",
1177
+ "assistant",
1178
+ "toolResult",
1179
+ "toolResult",
1180
+ "assistant",
1181
+ ]);
1182
+ });
1183
+
1184
+ it("should allow afterToolCall to mark a tool batch as terminating", async () => {
1185
+ const toolSchema = Type.Object({ value: Type.String() });
1186
+ const tool: AgentTool<typeof toolSchema, { value: string }> = {
1187
+ name: "echo",
1188
+ label: "Echo",
1189
+ description: "Echo tool",
1190
+ parameters: toolSchema,
1191
+ async execute(_toolCallId, params) {
1192
+ return {
1193
+ content: [{ type: "text", text: `echoed: ${params.value}` }],
1194
+ details: { value: params.value },
1195
+ };
1196
+ },
1197
+ };
1198
+
1199
+ const context: AgentContext = {
1200
+ systemPrompt: "",
1201
+ messages: [],
1202
+ tools: [tool],
1203
+ };
1204
+
1205
+ const config: AgentLoopConfig = {
1206
+ model: createModel(),
1207
+ convertToLlm: identityConverter,
1208
+ afterToolCall: async () => ({ terminate: true }),
1209
+ };
1210
+
1211
+ let llmCalls = 0;
1212
+ const stream = agentLoop([createUserMessage("echo something")], context, config, undefined, () => {
1213
+ llmCalls++;
1214
+ const mockStream = new MockAssistantStream();
1215
+ queueMicrotask(() => {
1216
+ const message = createAssistantMessage(
1217
+ [{ type: "toolCall", id: "tool-1", name: "echo", arguments: { value: "hello" } }],
1218
+ "toolUse",
1219
+ );
1220
+ mockStream.push({ type: "done", reason: "toolUse", message });
1221
+ });
1222
+ return mockStream;
1223
+ });
1224
+
1225
+ for await (const _event of stream) {
1226
+ // consume
1227
+ }
1228
+
1229
+ expect(llmCalls).toBe(1);
1230
+ });
1231
+ });
1232
+
1233
+ describe("agentLoopContinue with AgentMessage", () => {
1234
+ it("should throw when context has no messages", () => {
1235
+ const context: AgentContext = {
1236
+ systemPrompt: "You are helpful.",
1237
+ messages: [],
1238
+ tools: [],
1239
+ };
1240
+
1241
+ const config: AgentLoopConfig = {
1242
+ model: createModel(),
1243
+ convertToLlm: identityConverter,
1244
+ };
1245
+
1246
+ expect(() => agentLoopContinue(context, config)).toThrow("Cannot continue: no messages in context");
1247
+ });
1248
+
1249
+ it("should continue from existing context without emitting user message events", async () => {
1250
+ const userMessage: AgentMessage = createUserMessage("Hello");
1251
+
1252
+ const context: AgentContext = {
1253
+ systemPrompt: "You are helpful.",
1254
+ messages: [userMessage],
1255
+ tools: [],
1256
+ };
1257
+
1258
+ const config: AgentLoopConfig = {
1259
+ model: createModel(),
1260
+ convertToLlm: identityConverter,
1261
+ };
1262
+
1263
+ const streamFn = () => {
1264
+ const stream = new MockAssistantStream();
1265
+ queueMicrotask(() => {
1266
+ const message = createAssistantMessage([{ type: "text", text: "Response" }]);
1267
+ stream.push({ type: "done", reason: "stop", message });
1268
+ });
1269
+ return stream;
1270
+ };
1271
+
1272
+ const events: AgentEvent[] = [];
1273
+ const stream = agentLoopContinue(context, config, undefined, streamFn);
1274
+
1275
+ for await (const event of stream) {
1276
+ events.push(event);
1277
+ }
1278
+
1279
+ const messages = await stream.result();
1280
+
1281
+ // Should only return the new assistant message (not the existing user message)
1282
+ expect(messages.length).toBe(1);
1283
+ expect(messages[0].role).toBe("assistant");
1284
+
1285
+ // Should NOT have user message events (that's the key difference from agentLoop)
1286
+ const messageEndEvents = events.filter((e) => e.type === "message_end");
1287
+ expect(messageEndEvents.length).toBe(1);
1288
+ expect((messageEndEvents[0] as any).message.role).toBe("assistant");
1289
+ });
1290
+
1291
+ it("should allow custom message types as last message (caller responsibility)", async () => {
1292
+ // Custom message that will be converted to user message by convertToLlm
1293
+ interface CustomMessage {
1294
+ role: "custom";
1295
+ text: string;
1296
+ timestamp: number;
1297
+ }
1298
+
1299
+ const customMessage: CustomMessage = {
1300
+ role: "custom",
1301
+ text: "Hook content",
1302
+ timestamp: Date.now(),
1303
+ };
1304
+
1305
+ const context: AgentContext = {
1306
+ systemPrompt: "You are helpful.",
1307
+ messages: [customMessage as unknown as AgentMessage],
1308
+ tools: [],
1309
+ };
1310
+
1311
+ const config: AgentLoopConfig = {
1312
+ model: createModel(),
1313
+ convertToLlm: (messages) => {
1314
+ // Convert custom to user message
1315
+ return messages
1316
+ .map((m) => {
1317
+ if ((m as any).role === "custom") {
1318
+ return {
1319
+ role: "user" as const,
1320
+ content: (m as any).text,
1321
+ timestamp: m.timestamp,
1322
+ };
1323
+ }
1324
+ return m;
1325
+ })
1326
+ .filter((m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult") as Message[];
1327
+ },
1328
+ };
1329
+
1330
+ const streamFn = () => {
1331
+ const stream = new MockAssistantStream();
1332
+ queueMicrotask(() => {
1333
+ const message = createAssistantMessage([{ type: "text", text: "Response to custom message" }]);
1334
+ stream.push({ type: "done", reason: "stop", message });
1335
+ });
1336
+ return stream;
1337
+ };
1338
+
1339
+ // Should not throw - the custom message will be converted to user message
1340
+ const stream = agentLoopContinue(context, config, undefined, streamFn);
1341
+
1342
+ const events: AgentEvent[] = [];
1343
+ for await (const event of stream) {
1344
+ events.push(event);
1345
+ }
1346
+
1347
+ const messages = await stream.result();
1348
+ expect(messages.length).toBe(1);
1349
+ expect(messages[0].role).toBe("assistant");
1350
+ });
1351
+ });