laohuang 0.3.2 → 0.4.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 (86) hide show
  1. package/README.md +142 -16
  2. package/dist/agent.d.ts +166 -0
  3. package/dist/agent.js +858 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/bash-runner.d.ts +79 -0
  6. package/dist/bash-runner.js +464 -0
  7. package/dist/bash-runner.js.map +1 -0
  8. package/dist/cancellation.d.ts +36 -0
  9. package/dist/cancellation.js +123 -0
  10. package/dist/cancellation.js.map +1 -0
  11. package/dist/cli.d.ts +117 -0
  12. package/dist/cli.js +1307 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/client.d.ts +21 -0
  15. package/dist/client.js +17 -0
  16. package/dist/client.js.map +1 -0
  17. package/dist/commands.d.ts +123 -0
  18. package/dist/commands.js +660 -0
  19. package/dist/commands.js.map +1 -0
  20. package/dist/config.d.ts +51 -0
  21. package/dist/config.js +183 -0
  22. package/dist/config.js.map +1 -0
  23. package/dist/credentials.d.ts +12 -0
  24. package/dist/credentials.js +102 -0
  25. package/dist/credentials.js.map +1 -0
  26. package/dist/events.d.ts +288 -0
  27. package/dist/events.js +838 -0
  28. package/dist/events.js.map +1 -0
  29. package/dist/model-adapter.d.ts +138 -0
  30. package/dist/model-adapter.js +244 -0
  31. package/dist/model-adapter.js.map +1 -0
  32. package/dist/model-selection.d.ts +67 -0
  33. package/dist/model-selection.js +148 -0
  34. package/dist/model-selection.js.map +1 -0
  35. package/dist/model-stream.d.ts +128 -0
  36. package/dist/model-stream.js +582 -0
  37. package/dist/model-stream.js.map +1 -0
  38. package/dist/project-instructions.d.ts +99 -0
  39. package/dist/project-instructions.js +348 -0
  40. package/dist/project-instructions.js.map +1 -0
  41. package/dist/providers.d.ts +9 -0
  42. package/dist/providers.js +26 -0
  43. package/dist/providers.js.map +1 -0
  44. package/dist/routing.d.ts +170 -0
  45. package/dist/routing.js +669 -0
  46. package/dist/routing.js.map +1 -0
  47. package/dist/semantic-classifier.d.ts +66 -0
  48. package/dist/semantic-classifier.js +86 -0
  49. package/dist/semantic-classifier.js.map +1 -0
  50. package/dist/session.d.ts +162 -0
  51. package/dist/session.js +871 -0
  52. package/dist/session.js.map +1 -0
  53. package/dist/system-prompt.d.ts +16 -0
  54. package/dist/system-prompt.js +42 -0
  55. package/dist/system-prompt.js.map +1 -0
  56. package/dist/terminal/editor.d.ts +161 -0
  57. package/dist/terminal/editor.js +1060 -0
  58. package/dist/terminal/editor.js.map +1 -0
  59. package/dist/terminal/input.d.ts +61 -0
  60. package/dist/terminal/input.js +276 -0
  61. package/dist/terminal/input.js.map +1 -0
  62. package/dist/terminal/markdown.d.ts +20 -0
  63. package/dist/terminal/markdown.js +621 -0
  64. package/dist/terminal/markdown.js.map +1 -0
  65. package/dist/terminal/screen.d.ts +66 -0
  66. package/dist/terminal/screen.js +624 -0
  67. package/dist/terminal/screen.js.map +1 -0
  68. package/dist/terminal/theme.d.ts +23 -0
  69. package/dist/terminal/theme.js +101 -0
  70. package/dist/terminal/theme.js.map +1 -0
  71. package/dist/terminal/ui.d.ts +285 -0
  72. package/dist/terminal/ui.js +1815 -0
  73. package/dist/terminal/ui.js.map +1 -0
  74. package/dist/tools.d.ts +95 -0
  75. package/dist/tools.js +444 -0
  76. package/dist/tools.js.map +1 -0
  77. package/dist/ui-state.d.ts +51 -0
  78. package/dist/ui-state.js +194 -0
  79. package/dist/ui-state.js.map +1 -0
  80. package/dist/web.d.ts +56 -0
  81. package/dist/web.js +247 -0
  82. package/dist/web.js.map +1 -0
  83. package/package.json +24 -21
  84. package/bin/laohuang.js +0 -10
  85. package/lib/launcher.js +0 -133
  86. package/vendor/laohuangcode-0.3.2-py3-none-any.whl +0 -0
package/dist/agent.js ADDED
@@ -0,0 +1,858 @@
1
+ /**
2
+ * The model/tool loop at the heart of laoHuangCode.
3
+ *
4
+ * One user turn streams model completions through the provider-neutral
5
+ * adapter boundary (model-adapter.ts), commits
6
+ * only fully validated attempts to history (atomically, via the owning
7
+ * session's commit hooks when present), executes tool-call batches
8
+ * (read-only tools and multiple bash calls concurrently; any write/edit or
9
+ * sequential-mode tool makes the whole batch serial), and enforces the
10
+ * runtime guard rails: repeated-identical-tool-call detection plus token and
11
+ * duration budgets. A triggered guard disables tools and asks the model for
12
+ * one final answer from the information already gathered.
13
+ */
14
+ import { randomUUID } from "node:crypto";
15
+ import path from "node:path";
16
+ import { EventKind, EventSource, } from "./events.js";
17
+ import { ModelStreamCancelled, ModelStreamError, } from "./model-stream.js";
18
+ import { defaultAdapterRegistry, modelErrorKind, portableMessage, } from "./model-adapter.js";
19
+ import { touchedPathOf } from "./tools.js";
20
+ import { buildSystemPrompt } from "./system-prompt.js";
21
+ import { discoverInstructions, loadBaselineInstructions, realpathOrSelf, renderAdditionalInstructions, scopeChain, ProjectInstructionState, } from "./project-instructions.js";
22
+ export const FORCED_FINAL_PROMPT = `Tool use has been stopped by the runtime safety guard.
23
+ Do not call any tools. Give the user the best concise answer possible from the
24
+ information already available. Clearly state any limitation caused by stopping
25
+ tool use, but do not mention internal implementation details unless useful.`;
26
+ /** Raised when the model response cannot drive the agent loop. */
27
+ export class AgentError extends Error {
28
+ constructor(message, options = {}) {
29
+ super(message, options.cause === undefined ? undefined : { cause: options.cause });
30
+ this.name = "AgentError";
31
+ }
32
+ }
33
+ /** Raised when the active agent task is cooperatively cancelled. */
34
+ export class AgentCancelled extends AgentError {
35
+ constructor(message, options = {}) {
36
+ super(message, options);
37
+ this.name = "AgentCancelled";
38
+ }
39
+ }
40
+ const RUNTIME_EVENT_KINDS = {
41
+ model_request: EventKind.ModelRequestStarted,
42
+ model_text_delta: EventKind.ModelTextDelta,
43
+ model_reasoning_delta: EventKind.ModelReasoningDelta,
44
+ model_tool_call_delta: EventKind.ModelToolCallDelta,
45
+ model_response_validating: EventKind.ModelResponseValidating,
46
+ model_response_committed: EventKind.ModelResponseCommitted,
47
+ model_response_aborted: EventKind.ModelResponseAborted,
48
+ model_error: EventKind.ModelRequestFailed,
49
+ model_response: EventKind.ModelResponseSummary,
50
+ tool_start: EventKind.ToolStarted,
51
+ tool_result: EventKind.ToolFinished,
52
+ agent_guard_triggered: EventKind.AgentGuardTriggered,
53
+ agent_guard_failed: EventKind.AgentGuardFailed,
54
+ };
55
+ export class CodingAgent {
56
+ client;
57
+ model;
58
+ provider;
59
+ /** Provider-neutral model access; resolved from `provider`. */
60
+ adapter;
61
+ tools;
62
+ maxTotalTokens;
63
+ maxElapsedSeconds;
64
+ repeatedToolCallLimit;
65
+ toolExecution;
66
+ /** Conversation history in Chat Completions wire shape. */
67
+ messages;
68
+ onToolEvent;
69
+ onAgentEvent;
70
+ instructionRoot;
71
+ startupCwd;
72
+ baselineInstructionsLoaded = false;
73
+ instructionState = null;
74
+ turn = 0;
75
+ activeContext = null;
76
+ activeRequestId = null;
77
+ constructor(options) {
78
+ this.maxTotalTokens = options.maxTotalTokens ?? 100_000;
79
+ this.maxElapsedSeconds = options.maxElapsedSeconds ?? 300;
80
+ this.repeatedToolCallLimit = options.repeatedToolCallLimit ?? 3;
81
+ for (const [name, value] of [
82
+ ["maxTotalTokens", this.maxTotalTokens],
83
+ ["maxElapsedSeconds", this.maxElapsedSeconds],
84
+ ["repeatedToolCallLimit", this.repeatedToolCallLimit],
85
+ ]) {
86
+ if (value <= 0) {
87
+ throw new RangeError(`${name} must be positive`);
88
+ }
89
+ }
90
+ this.client = options.client;
91
+ this.model = options.model;
92
+ this.tools = options.tools;
93
+ this.onToolEvent = options.onToolEvent ?? null;
94
+ this.onAgentEvent = options.onAgentEvent ?? null;
95
+ this.provider = options.provider ?? null;
96
+ this.adapter = defaultAdapterRegistry.resolve(this.provider);
97
+ this.toolExecution = options.toolExecution ?? "parallel";
98
+ // Canonicalize so instruction scopes line up with the registry's
99
+ // realpath-resolved touched paths even when the cwd contains symlinks.
100
+ this.instructionRoot =
101
+ options.projectRoot == null ? null : realpathOrSelf(options.projectRoot);
102
+ this.startupCwd =
103
+ options.startupCwd == null ? null : realpathOrSelf(options.startupCwd);
104
+ this.messages = [{ role: "system", content: buildSystemPrompt(this.tools) }];
105
+ }
106
+ /** Bookkeeping for loaded project instructions (never model-visible). */
107
+ get projectInstructionState() {
108
+ return this.instructionState;
109
+ }
110
+ /** Swap the model client mid-conversation, keeping portable history. */
111
+ switchModel(options) {
112
+ const previousModel = this.model;
113
+ const previousProvider = this.provider;
114
+ this.messages = this.messages.map((message) => portableMessage(message));
115
+ this.client = options.client;
116
+ this.model = options.model;
117
+ this.provider = options.provider;
118
+ this.adapter = defaultAdapterRegistry.resolve(this.provider);
119
+ this.emit("model_switched", {
120
+ provider: options.provider,
121
+ model: options.model,
122
+ previous_model: previousModel,
123
+ previous_provider: previousProvider,
124
+ });
125
+ }
126
+ /** Run one user turn, committing only fully validated model attempts. */
127
+ async run(userInput, context = null, options = {}) {
128
+ let cancelToken = options.cancelToken ?? null;
129
+ if (context !== null && cancelToken === null) {
130
+ cancelToken = context.cancelToken ?? null;
131
+ }
132
+ this.activeContext = context;
133
+ this.turn += 1;
134
+ let toolRounds = 0;
135
+ let modelRound = 0;
136
+ let modelRequests = 0;
137
+ let totalTokens = 0;
138
+ const startedAt = performance.now();
139
+ const repeatedCalls = new Map();
140
+ let guardReason = null;
141
+ let guardEmitted = false;
142
+ try {
143
+ const userMessage = {
144
+ role: "user",
145
+ content: userInput,
146
+ };
147
+ const commitInput = context?.commitInput;
148
+ let committed;
149
+ if (typeof commitInput === "function") {
150
+ committed = this.commitContextMessage((append, rollback) => commitInput.call(context, append, rollback), userMessage);
151
+ }
152
+ else {
153
+ raiseIfCancelled(cancelToken);
154
+ this.messages.push(userMessage);
155
+ committed = true;
156
+ }
157
+ if (!committed) {
158
+ throw new AgentCancelled("cancelled before user input commit");
159
+ }
160
+ this.emit("user_message", { content: userInput });
161
+ this.injectBaselineInstructions(cancelToken);
162
+ for (;;) {
163
+ raiseIfCancelled(cancelToken);
164
+ guardReason =
165
+ guardReason ??
166
+ this.budgetGuardReason(totalTokens, (performance.now() - startedAt) / 1000);
167
+ const forceFinal = guardReason !== null;
168
+ if (guardReason !== null && !guardEmitted) {
169
+ guardEmitted = true;
170
+ this.emit("agent_guard_triggered", this.guardPayload({
171
+ reason: guardReason,
172
+ toolRounds,
173
+ modelRequests,
174
+ totalTokens,
175
+ startedAt,
176
+ }));
177
+ }
178
+ if (context?.modelStarted?.() === false) {
179
+ throw new AgentCancelled("cancelled before model request");
180
+ }
181
+ modelRound += 1;
182
+ modelRequests += 1;
183
+ const currentRequestId = modelRound === 1 && options.requestId
184
+ ? options.requestId
185
+ : randomUUID();
186
+ this.activeRequestId = currentRequestId;
187
+ this.emit("model_request", {
188
+ round: modelRound,
189
+ request_id: currentRequestId,
190
+ message_count: this.messages.length,
191
+ tool_rounds: toolRounds,
192
+ model_requests: modelRequests,
193
+ total_tokens: totalTokens,
194
+ force_final: forceFinal,
195
+ guard_reason: guardReason,
196
+ });
197
+ const requestMessages = [...this.messages];
198
+ if (forceFinal) {
199
+ const systemMessage = {
200
+ ...(requestMessages[0] ?? {}),
201
+ };
202
+ systemMessage["content"] =
203
+ `${String(systemMessage["content"] ?? "")}\n\n${FORCED_FINAL_PROMPT}`;
204
+ requestMessages[0] = systemMessage;
205
+ }
206
+ let result;
207
+ try {
208
+ const modelRequestOpened = context?.modelRequestOpened;
209
+ const isRequestActive = options.isRequestActive ??
210
+ ((requestId) => this.activeRequestId === requestId);
211
+ result = await this.adapter.complete(this.client, {
212
+ model: this.model,
213
+ messages: requestMessages,
214
+ tools: this.tools.definitions,
215
+ toolChoice: forceFinal ? "none" : "auto",
216
+ requestId: currentRequestId,
217
+ cancelToken,
218
+ isRequestActive,
219
+ onDelta: (kind, payload) => {
220
+ this.emit(kind, { round: modelRound, ...payload });
221
+ },
222
+ onRequestOpened: modelRequestOpened
223
+ ? () => modelRequestOpened.call(context)
224
+ : null,
225
+ });
226
+ }
227
+ catch (error) {
228
+ if (error instanceof ModelStreamCancelled) {
229
+ // Covers StaleModelRequest as well.
230
+ this.emit("model_response_aborted", {
231
+ round: modelRound,
232
+ request_id: currentRequestId,
233
+ reason: errorMessage(error),
234
+ });
235
+ throw new AgentCancelled(errorMessage(error), { cause: error });
236
+ }
237
+ const errorPayload = {
238
+ round: modelRound,
239
+ request_id: currentRequestId,
240
+ error: errorMessage(error),
241
+ };
242
+ if (error instanceof ModelStreamError && error.hadDelta) {
243
+ this.emit("model_response_aborted", errorPayload);
244
+ this.emitLegacy("model_error", errorPayload);
245
+ }
246
+ else {
247
+ this.emit("model_error", errorPayload);
248
+ }
249
+ if (forceFinal) {
250
+ const payload = this.guardPayload({
251
+ reason: guardReason ?? "runtime safety guard",
252
+ toolRounds,
253
+ modelRequests,
254
+ totalTokens,
255
+ startedAt,
256
+ finalError: errorMessage(error),
257
+ });
258
+ this.emit("agent_guard_failed", payload);
259
+ let message = guardErrorMessage(payload);
260
+ if (this.provider &&
261
+ modelErrorKind(error) === "authentication") {
262
+ message +=
263
+ ` Authentication failed for ${this.provider}. ` +
264
+ `Run /login ${this.provider} to update your API key.`;
265
+ }
266
+ throw new AgentError(message, { cause: error });
267
+ }
268
+ let message = `Model request failed: ${errorMessage(error)}`;
269
+ if (this.provider && modelErrorKind(error) === "authentication") {
270
+ message +=
271
+ `\nAuthentication failed for ${this.provider}. ` +
272
+ `Run /login ${this.provider} to update your API key.`;
273
+ }
274
+ throw new AgentError(message, { cause: error });
275
+ }
276
+ const toolCalls = [...result.toolCalls];
277
+ let requestTokens = usageTotalTokens(result.usage);
278
+ const tokensEstimated = requestTokens === 0;
279
+ if (tokensEstimated) {
280
+ requestTokens = estimateRequestTokens(requestMessages, result.messageDict());
281
+ }
282
+ totalTokens += requestTokens;
283
+ this.emit("model_response", {
284
+ round: modelRound,
285
+ request_id: currentRequestId,
286
+ finish_reason: result.finishReason,
287
+ tool_call_count: toolCalls.length,
288
+ tool_names: toolCalls.map((call) => call.function.name),
289
+ tool_call_ids: toolCalls.map((call) => call.id),
290
+ usage: result.usage,
291
+ request_tokens: requestTokens,
292
+ tokens_estimated: tokensEstimated,
293
+ total_tokens: totalTokens,
294
+ tool_rounds: toolRounds,
295
+ model_requests: modelRequests,
296
+ force_final: forceFinal,
297
+ });
298
+ if (forceFinal && toolCalls.length > 0) {
299
+ this.emit("model_response_aborted", {
300
+ round: modelRound,
301
+ request_id: currentRequestId,
302
+ reason: "tool call returned while tools were disabled",
303
+ });
304
+ const payload = this.guardPayload({
305
+ reason: guardReason ?? "runtime safety guard",
306
+ toolRounds,
307
+ modelRequests,
308
+ totalTokens,
309
+ startedAt,
310
+ });
311
+ this.emit("agent_guard_failed", payload);
312
+ throw new AgentError(guardErrorMessage(payload));
313
+ }
314
+ const postResponseGuard = this.budgetGuardReason(totalTokens, (performance.now() - startedAt) / 1000);
315
+ if (toolCalls.length > 0 && postResponseGuard !== null) {
316
+ this.emit("model_response_aborted", {
317
+ round: modelRound,
318
+ request_id: currentRequestId,
319
+ reason: postResponseGuard,
320
+ });
321
+ guardReason = postResponseGuard;
322
+ continue;
323
+ }
324
+ // The complete assistant message is committed only if cancellation
325
+ // has not won the Session coordination race.
326
+ const assistantMessage = result.messageDict();
327
+ const commitIfActive = context?.commitIfActive;
328
+ if (typeof commitIfActive === "function") {
329
+ committed = commitIfActive.call(context, () => {
330
+ this.messages.push(assistantMessage);
331
+ });
332
+ }
333
+ else {
334
+ raiseIfCancelled(cancelToken);
335
+ this.messages.push(assistantMessage);
336
+ committed = true;
337
+ }
338
+ if (!committed) {
339
+ this.emit("model_response_aborted", {
340
+ round: modelRound,
341
+ request_id: currentRequestId,
342
+ reason: "cancelled before history commit",
343
+ });
344
+ throw new AgentCancelled("cancelled before history commit");
345
+ }
346
+ this.emit("model_response_committed", {
347
+ round: modelRound,
348
+ request_id: currentRequestId,
349
+ });
350
+ if (toolCalls.length === 0) {
351
+ const content = result.content;
352
+ if (content === null) {
353
+ throw new AgentError("Model response had no content");
354
+ }
355
+ this.emit("assistant_response", {
356
+ round: modelRound,
357
+ content: truncateForEvent(content),
358
+ });
359
+ return content;
360
+ }
361
+ toolRounds += 1;
362
+ context?.toolsStarted?.();
363
+ const toolResults = await this.executeToolBatch(toolCalls, modelRound, cancelToken, context);
364
+ const repeated = this.recordRepeatedToolCalls(toolCalls, toolResults, repeatedCalls);
365
+ // Every committed assistant tool call must receive one paired tool
366
+ // result, including calls cancelled before they start.
367
+ for (let index = 0; index < toolCalls.length; index += 1) {
368
+ this.messages.push({
369
+ role: "tool",
370
+ tool_call_id: toolCalls[index]?.id,
371
+ content: JSON.stringify(toolResults[index]),
372
+ });
373
+ }
374
+ raiseIfCancelled(cancelToken);
375
+ // Dynamic descendant discovery runs only after every paired tool
376
+ // result is committed, so reminders land between the tool results
377
+ // and the next model request without touching earlier history.
378
+ const touchedPaths = [];
379
+ for (const result of toolResults) {
380
+ const touched = touchedPathOf(result);
381
+ if (typeof touched === "string") {
382
+ touchedPaths.push(touched);
383
+ }
384
+ }
385
+ this.discoverForTouchedPaths(touchedPaths);
386
+ if (repeated !== null) {
387
+ guardReason =
388
+ `repeated tool call detected (${repeated.name} repeated ` +
389
+ `${repeated.count} times with the same arguments and result)`;
390
+ }
391
+ const safePoint = context?.safePoint;
392
+ if (context !== null && typeof safePoint === "function") {
393
+ const pendingBatch = safePoint.call(context);
394
+ const pendingContent = pendingBatch?.content ?? "";
395
+ if (pendingBatch != null && pendingContent) {
396
+ const pendingMessage = {
397
+ role: "user",
398
+ content: pendingContent,
399
+ };
400
+ const commitPending = context.commitPending;
401
+ let committedPending;
402
+ if (typeof commitPending === "function") {
403
+ committedPending = this.commitContextMessage((append, rollback) => commitPending.call(context, pendingBatch, append, rollback), pendingMessage);
404
+ }
405
+ else {
406
+ raiseIfCancelled(cancelToken);
407
+ this.messages.push(pendingMessage);
408
+ committedPending = true;
409
+ }
410
+ if (!committedPending) {
411
+ throw new AgentCancelled("cancelled before pending input commit");
412
+ }
413
+ this.emit("user_message", {
414
+ content: pendingContent,
415
+ pending_event_ids: [...(pendingBatch.eventIds ?? [])],
416
+ });
417
+ }
418
+ }
419
+ }
420
+ }
421
+ finally {
422
+ this.activeRequestId = null;
423
+ this.activeContext = null;
424
+ }
425
+ }
426
+ // --- Guard rails -----------------------------------------------------------
427
+ budgetGuardReason(totalTokens, elapsedSeconds) {
428
+ if (totalTokens >= this.maxTotalTokens) {
429
+ return `token budget reached (${this.maxTotalTokens})`;
430
+ }
431
+ if (elapsedSeconds >= this.maxElapsedSeconds) {
432
+ return (`elapsed time budget reached (${String(this.maxElapsedSeconds)} seconds)`);
433
+ }
434
+ return null;
435
+ }
436
+ guardPayload(options) {
437
+ const payload = {
438
+ reason: options.reason,
439
+ tool_rounds: options.toolRounds,
440
+ model_requests: options.modelRequests,
441
+ total_tokens: options.totalTokens,
442
+ elapsed_ms: Math.round(performance.now() - options.startedAt),
443
+ };
444
+ if (options.finalError) {
445
+ payload["final_error"] = options.finalError;
446
+ }
447
+ return payload;
448
+ }
449
+ recordRepeatedToolCalls(toolCalls, toolResults, counts) {
450
+ let repeated = null;
451
+ const seen = new Set();
452
+ for (let index = 0; index < toolCalls.length; index += 1) {
453
+ const toolCall = toolCalls[index];
454
+ if (toolCall === undefined) {
455
+ continue;
456
+ }
457
+ let parsedArguments;
458
+ try {
459
+ parsedArguments = JSON.parse(toolCall.function.arguments);
460
+ }
461
+ catch {
462
+ parsedArguments = toolCall.function.arguments;
463
+ }
464
+ const fingerprint = stableStringify({
465
+ name: toolCall.function.name,
466
+ arguments: parsedArguments,
467
+ result: stableToolResult(toolResults[index]),
468
+ });
469
+ const count = (counts.get(fingerprint) ?? 0) + 1;
470
+ counts.set(fingerprint, count);
471
+ seen.add(fingerprint);
472
+ if (repeated === null || count > repeated.count) {
473
+ repeated = { name: toolCall.function.name, count };
474
+ }
475
+ }
476
+ for (const fingerprint of [...counts.keys()]) {
477
+ if (!seen.has(fingerprint)) {
478
+ counts.delete(fingerprint);
479
+ }
480
+ }
481
+ return repeated !== null && repeated.count >= this.repeatedToolCallLimit
482
+ ? repeated
483
+ : null;
484
+ }
485
+ // --- Tool batch execution ----------------------------------------------------
486
+ async executeToolBatch(toolCalls, modelRound, cancelToken, context) {
487
+ const results = new Array(toolCalls.length).fill(undefined);
488
+ const prepared = [];
489
+ for (let offset = 0; offset < toolCalls.length; offset += 1) {
490
+ const toolCall = toolCalls[offset];
491
+ if (toolCall === undefined) {
492
+ continue;
493
+ }
494
+ let args;
495
+ let result;
496
+ try {
497
+ const decoded = JSON.parse(toolCall.function.arguments);
498
+ if (typeof decoded !== "object" ||
499
+ decoded === null ||
500
+ Array.isArray(decoded)) {
501
+ throw new Error("Tool arguments must be a JSON object");
502
+ }
503
+ args = decoded;
504
+ }
505
+ catch (error) {
506
+ args = { _raw: toolCall.function.arguments };
507
+ result = { ok: false, error: errorMessage(error) };
508
+ }
509
+ const eventContext = {
510
+ round: modelRound,
511
+ index: offset + 1,
512
+ batch_size: toolCalls.length,
513
+ tool_call_id: toolCall.id,
514
+ name: toolCall.function.name,
515
+ };
516
+ this.emit("tool_start", {
517
+ ...eventContext,
518
+ arguments: safeArguments(args),
519
+ });
520
+ if (result === undefined) {
521
+ prepared.push({ offset, toolCall, args, eventContext });
522
+ }
523
+ else {
524
+ results[offset] = result;
525
+ this.finishToolEvent(toolCall.function.name, args, result, eventContext);
526
+ }
527
+ }
528
+ const sequentialBatch = this.toolExecution === "sequential" ||
529
+ toolCalls.some((call) => this.tools.executionMode(call.function.name) === "sequential") ||
530
+ toolCalls.some((call) => call.function.name === "write" || call.function.name === "edit");
531
+ const runOne = async (item) => {
532
+ const result = isCancelled(cancelToken)
533
+ ? cancelledToolResult(cancelToken)
534
+ : await this.executeTool(item.toolCall.function.name, item.args, item.toolCall.id, cancelToken, context);
535
+ results[item.offset] = result;
536
+ // Finish events fire in actual completion order; the returned results
537
+ // array keeps the original call order for the model.
538
+ this.finishToolEvent(item.toolCall.function.name, item.args, result, item.eventContext);
539
+ };
540
+ if (sequentialBatch || prepared.length === 1) {
541
+ for (const item of prepared) {
542
+ await runOne(item);
543
+ }
544
+ }
545
+ else if (prepared.length > 0) {
546
+ await Promise.all(prepared.map((item) => runOne(item)));
547
+ }
548
+ return results.map((result) => result ?? { ok: false, error: "Tool execution produced no result" });
549
+ }
550
+ async executeTool(name, args, toolCallId, cancelToken, context) {
551
+ if (isCancelled(cancelToken)) {
552
+ return cancelledToolResult(cancelToken);
553
+ }
554
+ try {
555
+ const toolContext = makeToolContext(context, toolCallId, cancelToken);
556
+ return await this.tools.execute(name, args, toolContext);
557
+ }
558
+ catch (error) {
559
+ return { ok: false, error: errorMessage(error) };
560
+ }
561
+ }
562
+ finishToolEvent(name, args, result, eventContext) {
563
+ this.onToolEvent?.(name, args, result);
564
+ const status = result["status"];
565
+ this.emit("tool_result", {
566
+ ...eventContext,
567
+ status: (typeof status === "string" && status) ||
568
+ (result["ok"] ? "completed" : "failed"),
569
+ result: safeResult(result),
570
+ });
571
+ }
572
+ // --- History commits ---------------------------------------------------------
573
+ /**
574
+ * Append the rendered baseline project instructions once per session,
575
+ * right after the first direct user message and before the first model
576
+ * request. Append-only: the system prompt and committed history are never
577
+ * rebuilt, and nothing is appended when no instruction files exist.
578
+ */
579
+ injectBaselineInstructions(cancelToken) {
580
+ if (this.baselineInstructionsLoaded) {
581
+ return;
582
+ }
583
+ this.baselineInstructionsLoaded = true;
584
+ if (this.instructionRoot === null || this.startupCwd === null) {
585
+ return;
586
+ }
587
+ const baseline = loadBaselineInstructions(this.instructionRoot, this.startupCwd);
588
+ this.instructionState = baseline.state;
589
+ if (baseline.rendered === "") {
590
+ return;
591
+ }
592
+ raiseIfCancelled(cancelToken);
593
+ this.messages.push({ role: "user", content: baseline.rendered });
594
+ }
595
+ /**
596
+ * DSH-style dynamic descendant discovery: for every parent directory from
597
+ * the instruction root to each successfully touched path's directory,
598
+ * render instructions from scopes not already represented by the
599
+ * instruction state as one additional reminder message. Append-only and
600
+ * duplicate-suppressed by ProjectInstructionState; a no-op when
601
+ * instruction loading is not configured.
602
+ */
603
+ discoverForTouchedPaths(touchedPaths) {
604
+ const root = this.instructionRoot;
605
+ if (root === null || touchedPaths.length === 0) {
606
+ return;
607
+ }
608
+ const state = (this.instructionState ??= new ProjectInstructionState());
609
+ const dirs = [];
610
+ const seen = new Set();
611
+ for (const touched of touchedPaths) {
612
+ const relative = path.relative(root, touched);
613
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
614
+ continue; // Out-of-root operations yield no discovery.
615
+ }
616
+ for (const directory of scopeChain(root, path.dirname(touched))) {
617
+ if (!seen.has(directory)) {
618
+ seen.add(directory);
619
+ dirs.push(directory);
620
+ }
621
+ }
622
+ }
623
+ if (dirs.length === 0) {
624
+ return;
625
+ }
626
+ const files = discoverInstructions(root, dirs, {}, state);
627
+ const rendered = renderAdditionalInstructions(files);
628
+ if (rendered === "") {
629
+ return;
630
+ }
631
+ this.messages.push({ role: "user", content: rendered });
632
+ }
633
+ commitContextMessage(commit, message) {
634
+ const append = () => {
635
+ this.messages.push(message);
636
+ };
637
+ const rollback = () => {
638
+ if (this.messages.at(-1) === message) {
639
+ this.messages.pop();
640
+ }
641
+ };
642
+ return Boolean(commit(append, rollback));
643
+ }
644
+ // --- Events --------------------------------------------------------------------
645
+ emit(eventType, payload) {
646
+ const fullPayload = { turn: this.turn, ...payload };
647
+ this.emitLegacy(eventType, fullPayload, false);
648
+ this.publishRuntimeEvent(eventType, fullPayload);
649
+ }
650
+ emitLegacy(eventType, payload, addTurn = true) {
651
+ if (this.onAgentEvent !== null) {
652
+ const body = addTurn ? { turn: this.turn, ...payload } : payload;
653
+ this.onAgentEvent(eventType, body);
654
+ }
655
+ }
656
+ publishRuntimeEvent(eventType, payload) {
657
+ const context = this.activeContext;
658
+ const publisher = context?.publish;
659
+ const bus = context?.eventBus ?? null;
660
+ if (typeof publisher !== "function" && bus === null) {
661
+ return;
662
+ }
663
+ const kindName = RUNTIME_EVENT_KINDS[eventType];
664
+ if (kindName === undefined) {
665
+ return;
666
+ }
667
+ // Streaming Bash owns its own start/output/finish events. The agent
668
+ // supplies lifecycle events for the three synchronous file tools.
669
+ const isToolEvent = eventType === "tool_start" || eventType === "tool_result";
670
+ const isGuardEvent = eventType === "agent_guard_triggered" ||
671
+ eventType === "agent_guard_failed";
672
+ if (isToolEvent && payload["name"] === "bash") {
673
+ return;
674
+ }
675
+ const source = isToolEvent
676
+ ? EventSource.Tool
677
+ : isGuardEvent
678
+ ? EventSource.System
679
+ : EventSource.Model;
680
+ const correlationId = isToolEvent
681
+ ? (payload["tool_call_id"] ?? null)
682
+ : isGuardEvent
683
+ ? null
684
+ : (payload["request_id"] ?? null);
685
+ if (typeof publisher === "function") {
686
+ publisher.call(context, kindName, {
687
+ source,
688
+ correlation_id: correlationId,
689
+ payload,
690
+ });
691
+ }
692
+ else if (bus !== null) {
693
+ bus.publish(kindName, busPublishOptions(source, context?.sessionId ?? null, context?.taskId ?? null, correlationId, payload));
694
+ }
695
+ }
696
+ }
697
+ // --- Module-level helpers ------------------------------------------------------
698
+ function makeToolContext(context, toolCallId, cancelToken) {
699
+ const sessionId = context?.sessionId ?? null;
700
+ const taskId = context?.taskId ?? null;
701
+ return {
702
+ sessionId,
703
+ taskId,
704
+ toolCallId,
705
+ cancelToken,
706
+ isCancelled: () => isCancelled(cancelToken),
707
+ cancellationReason: cancelToken?.reason || "cancelled",
708
+ publish: (kind, payload) => {
709
+ if (typeof context?.publish === "function") {
710
+ context.publish(kind, {
711
+ source: EventSource.Tool,
712
+ correlation_id: toolCallId,
713
+ payload,
714
+ });
715
+ return;
716
+ }
717
+ const bus = context?.eventBus ?? null;
718
+ if (bus !== null) {
719
+ bus.publish(kind, busPublishOptions(EventSource.Tool, sessionId, taskId, toolCallId, payload));
720
+ }
721
+ },
722
+ };
723
+ }
724
+ function busPublishOptions(source, sessionId, taskId, correlationId, payload) {
725
+ return {
726
+ source,
727
+ session_id: sessionId || "local",
728
+ task_id: taskId,
729
+ correlation_id: correlationId,
730
+ payload,
731
+ };
732
+ }
733
+ function isCancelled(token) {
734
+ return token !== null && token.isCancelled();
735
+ }
736
+ function raiseIfCancelled(token) {
737
+ if (isCancelled(token)) {
738
+ throw new AgentCancelled(token?.reason || "cancelled");
739
+ }
740
+ }
741
+ function cancelledToolResult(token) {
742
+ return {
743
+ ok: false,
744
+ status: "cancelled",
745
+ error: token?.reason || "cancelled",
746
+ };
747
+ }
748
+ function usageTotalTokens(usage) {
749
+ if (usage === null || usage === undefined || typeof usage !== "object") {
750
+ return 0;
751
+ }
752
+ const record = usage;
753
+ const total = record["total_tokens"];
754
+ if (isJsonInteger(total)) {
755
+ return Math.max(0, total);
756
+ }
757
+ const input = record["prompt_tokens"] ?? record["input_tokens"] ?? 0;
758
+ const output = record["completion_tokens"] ?? record["output_tokens"] ?? 0;
759
+ let sum = 0;
760
+ for (const value of [input, output]) {
761
+ if (isJsonInteger(value) && value > 0) {
762
+ sum += value;
763
+ }
764
+ }
765
+ return sum;
766
+ }
767
+ function isJsonInteger(value) {
768
+ return typeof value === "number" && Number.isInteger(value);
769
+ }
770
+ /** Rough 4-bytes-per-token estimate when the API returns no usage. */
771
+ function estimateRequestTokens(messages, response) {
772
+ const serialized = JSON.stringify([...messages, response]);
773
+ return Math.max(1, Math.floor((Buffer.byteLength(serialized, "utf8") + 3) / 4));
774
+ }
775
+ /** Recursively drop the volatile duration_ms field from tool results. */
776
+ function stableToolResult(value) {
777
+ if (Array.isArray(value)) {
778
+ return value.map((item) => stableToolResult(item));
779
+ }
780
+ if (value !== null && typeof value === "object") {
781
+ const result = {};
782
+ for (const [key, item] of Object.entries(value)) {
783
+ if (key !== "duration_ms") {
784
+ result[key] = stableToolResult(item);
785
+ }
786
+ }
787
+ return result;
788
+ }
789
+ return value;
790
+ }
791
+ /** Compact JSON with sorted object keys (Python json.dumps sort_keys). */
792
+ function stableStringify(value) {
793
+ if (value === null) {
794
+ return "null";
795
+ }
796
+ if (Array.isArray(value)) {
797
+ return `[${value.map((item) => stableStringify(item)).join(",")}]`;
798
+ }
799
+ if (typeof value === "object") {
800
+ const record = value;
801
+ const parts = Object.keys(record)
802
+ .filter((key) => record[key] !== undefined)
803
+ .sort()
804
+ .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`);
805
+ return `{${parts.join(",")}}`;
806
+ }
807
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
808
+ return JSON.stringify(value);
809
+ }
810
+ return JSON.stringify(String(value));
811
+ }
812
+ function guardErrorMessage(payload) {
813
+ let message = "Agent safety guard stopped tool use but could not produce a final " +
814
+ `answer: ${String(payload["reason"])}. ` +
815
+ `Tool rounds: ${String(payload["tool_rounds"])}; ` +
816
+ `model requests: ${String(payload["model_requests"])}; ` +
817
+ `tokens counted: ${String(payload["total_tokens"])}; ` +
818
+ `elapsed: ${String(payload["elapsed_ms"])}ms.`;
819
+ if (payload["final_error"]) {
820
+ message += ` Final request failed: ${String(payload["final_error"])}`;
821
+ }
822
+ return message;
823
+ }
824
+ function safeArguments(args) {
825
+ const safe = { ...args };
826
+ for (const key of ["content", "old_text", "new_text"]) {
827
+ const value = safe[key];
828
+ if (typeof value === "string") {
829
+ safe[key] = `<${value.length} chars>`;
830
+ }
831
+ }
832
+ const edits = safe["edits"];
833
+ if (Array.isArray(edits)) {
834
+ safe["edits"] = `<${edits.length} edits>`;
835
+ }
836
+ const result = {};
837
+ for (const [key, value] of Object.entries(safe)) {
838
+ result[key] = typeof value === "string" ? truncateForEvent(value) : value;
839
+ }
840
+ return result;
841
+ }
842
+ function safeResult(result) {
843
+ const safe = {};
844
+ for (const [key, value] of Object.entries(result)) {
845
+ safe[key] = typeof value === "string" ? truncateForEvent(value) : value;
846
+ }
847
+ return safe;
848
+ }
849
+ function truncateForEvent(value, limit = 4_000) {
850
+ if (value.length <= limit) {
851
+ return value;
852
+ }
853
+ return `${value.slice(0, limit)}\n...[truncated ${value.length - limit} chars]`;
854
+ }
855
+ function errorMessage(error) {
856
+ return error instanceof Error ? error.message : String(error);
857
+ }
858
+ //# sourceMappingURL=agent.js.map