@clinebot/agents 0.0.0 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/agent.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { providers } from "@clinebot/llms";
8
+ import { nanoid } from "nanoid";
8
9
  import { buildInitialUserContent } from "./agent-input.js";
9
10
  import {
10
11
  type ContributionRegistry,
@@ -26,6 +27,7 @@ import type {
26
27
  AgentResult,
27
28
  AgentUsage,
28
29
  BasicLogger,
30
+ ConsecutiveMistakeLimitDecision,
29
31
  PendingToolCall,
30
32
  Tool,
31
33
  ToolApprovalResult,
@@ -37,6 +39,36 @@ import type {
37
39
  const DEFAULT_REMINDER_TEXT =
38
40
  "REMINDER: If you have gathered enough information to answer the user's question, please provide your final answer now without using any more tools.";
39
41
 
42
+ function isNonRecoverableApiError(error: Error): boolean {
43
+ const message = error.message.toLowerCase();
44
+
45
+ const nonRecoverableStatusCodes = [
46
+ 400, 401, 403, 404, 405, 406, 409, 410, 429,
47
+ ];
48
+ if (
49
+ nonRecoverableStatusCodes.some((code) =>
50
+ new RegExp(`(?:\\b|\\"code\\"\\s*:\\s*)${code}(?:\\b|\\s)`).test(message),
51
+ )
52
+ ) {
53
+ return true;
54
+ }
55
+
56
+ if (
57
+ [
58
+ "not found",
59
+ "unsupported for",
60
+ "invalid api key",
61
+ "authentication",
62
+ "unauthorized",
63
+ "forbidden",
64
+ ].some((s) => message.includes(s))
65
+ ) {
66
+ return true;
67
+ }
68
+
69
+ return false;
70
+ }
71
+
40
72
  function resolveKnownModelsFromConfig(
41
73
  config: AgentConfig,
42
74
  ): Record<string, providers.ModelInfo> | undefined {
@@ -71,6 +103,7 @@ export class Agent {
71
103
  | "tools"
72
104
  | "maxParallelToolCalls"
73
105
  | "apiTimeoutMs"
106
+ | "maxConsecutiveMistakes"
74
107
  | "maxTokensPerTurn"
75
108
  | "reminderAfterIterations"
76
109
  | "reminderText"
@@ -102,15 +135,16 @@ export class Agent {
102
135
  maxIterations: config.maxIterations,
103
136
  maxParallelToolCalls: config.maxParallelToolCalls ?? 8,
104
137
  apiTimeoutMs: config.apiTimeoutMs ?? 120000,
138
+ maxConsecutiveMistakes: config.maxConsecutiveMistakes ?? 0,
105
139
  maxTokensPerTurn: config.maxTokensPerTurn ?? 8192,
106
- reminderAfterIterations: config.reminderAfterIterations ?? 50,
140
+ reminderAfterIterations: config.reminderAfterIterations ?? 0,
107
141
  reminderText: config.reminderText ?? DEFAULT_REMINDER_TEXT,
108
142
  hookErrorMode: config.hookErrorMode ?? "ignore",
109
143
  extensions: config.extensions ?? [],
110
144
  toolPolicies: config.toolPolicies ?? {},
111
145
  };
112
146
 
113
- this.agentId = `agent_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
147
+ this.agentId = `agent_${Date.now()}_${nanoid(6)}`;
114
148
  this.parentAgentId = config.parentAgentId ?? null;
115
149
  this.conversationStore = new ConversationStore(
116
150
  config.initialMessages ?? [],
@@ -410,6 +444,7 @@ export class Agent {
410
444
  cacheWriteTokens: 0,
411
445
  totalCost: undefined,
412
446
  };
447
+ let consecutiveMistakes = 0;
413
448
 
414
449
  try {
415
450
  if (!this.conversationStore.isSessionStarted()) {
@@ -536,12 +571,49 @@ export class Agent {
536
571
  );
537
572
  }
538
573
 
539
- const { turn, assistantMessage } = await this.turnProcessor.processTurn(
540
- this.conversationStore.getMessages(),
541
- turnSystemPrompt,
542
- this.config.tools,
543
- abortSignal,
544
- );
574
+ let turn: Awaited<ReturnType<TurnProcessor["processTurn"]>>["turn"];
575
+ let assistantMessage:
576
+ | Awaited<
577
+ ReturnType<TurnProcessor["processTurn"]>
578
+ >["assistantMessage"]
579
+ | undefined;
580
+ try {
581
+ ({ turn, assistantMessage } = await this.turnProcessor.processTurn(
582
+ this.conversationStore.getMessages(),
583
+ turnSystemPrompt,
584
+ this.config.tools,
585
+ abortSignal,
586
+ ));
587
+ } catch (error) {
588
+ const errorObj =
589
+ error instanceof Error ? error : new Error(String(error));
590
+ const message = errorObj.message;
591
+ if (isNonRecoverableApiError(errorObj)) {
592
+ throw errorObj;
593
+ }
594
+ this.conversationStore.appendMessage({
595
+ role: "user",
596
+ content: [
597
+ {
598
+ type: "text",
599
+ text: `The previous turn failed with an API/runtime error: ${message}. Retry and continue from the latest state.`,
600
+ },
601
+ ],
602
+ });
603
+ const shouldContinue = await this.recordMistake({
604
+ iteration,
605
+ reason: "api_error",
606
+ details: message,
607
+ consecutiveMistakes: () => consecutiveMistakes,
608
+ setConsecutiveMistakes: (value) => {
609
+ consecutiveMistakes = value;
610
+ },
611
+ });
612
+ if (shouldContinue) {
613
+ continue;
614
+ }
615
+ throw errorObj;
616
+ }
545
617
  if (assistantMessage) {
546
618
  this.conversationStore.appendMessage(assistantMessage);
547
619
  }
@@ -586,7 +658,35 @@ export class Agent {
586
658
  totalCost: totalUsage.totalCost,
587
659
  });
588
660
 
661
+ if (turn.invalidToolCalls.length > 0) {
662
+ this.conversationStore.appendMessage({
663
+ role: "user",
664
+ content: [
665
+ {
666
+ type: "text",
667
+ text: this.buildInvalidToolCallFeedback(turn.invalidToolCalls),
668
+ },
669
+ ],
670
+ });
671
+ const shouldContinue = await this.recordMistake({
672
+ iteration,
673
+ reason: "invalid_tool_call",
674
+ details: `${turn.invalidToolCalls.length} invalid tool call(s)`,
675
+ consecutiveMistakes: () => consecutiveMistakes,
676
+ setConsecutiveMistakes: (value) => {
677
+ consecutiveMistakes = value;
678
+ },
679
+ });
680
+ if (shouldContinue) {
681
+ continue;
682
+ }
683
+ throw new Error(
684
+ `maximum consecutive mistakes reached (${this.config.maxConsecutiveMistakes})`,
685
+ );
686
+ }
687
+
589
688
  if (turn.toolCalls.length === 0) {
689
+ consecutiveMistakes = 0;
590
690
  // Check completion guard before allowing the loop to end.
591
691
  // If the guard returns a nudge string, inject it and continue.
592
692
  const guardNudge = this.config.completionGuard?.();
@@ -648,6 +748,32 @@ export class Agent {
648
748
  text: this.config.reminderText,
649
749
  }),
650
750
  );
751
+ const successfulToolCalls = toolResults.filter(
752
+ (record) => !record.error,
753
+ ).length;
754
+ const failedToolCalls = toolResults.length - successfulToolCalls;
755
+ if (successfulToolCalls > 0) {
756
+ consecutiveMistakes = 0;
757
+ } else if (failedToolCalls > 0) {
758
+ const failedToolCallDetails =
759
+ this.buildFailedToolCallFeedback(toolResults);
760
+ const shouldContinue = await this.recordMistake({
761
+ iteration,
762
+ reason: "tool_execution_failed",
763
+ details: `${failedToolCalls} tool call(s) failed${
764
+ failedToolCallDetails ? `: ${failedToolCallDetails}` : ""
765
+ }`,
766
+ consecutiveMistakes: () => consecutiveMistakes,
767
+ setConsecutiveMistakes: (value) => {
768
+ consecutiveMistakes = value;
769
+ },
770
+ });
771
+ if (!shouldContinue) {
772
+ throw new Error(
773
+ `maximum consecutive mistakes reached (${this.config.maxConsecutiveMistakes})`,
774
+ );
775
+ }
776
+ }
651
777
 
652
778
  this.emit({
653
779
  type: "iteration_end",
@@ -770,6 +896,127 @@ export class Agent {
770
896
  return result;
771
897
  }
772
898
 
899
+ private buildInvalidToolCallFeedback(
900
+ invalidToolCalls: Array<{
901
+ id: string;
902
+ name?: string;
903
+ reason: "missing_name" | "missing_arguments" | "invalid_arguments";
904
+ }>,
905
+ ): string {
906
+ const details = invalidToolCalls
907
+ .map((call) => {
908
+ const name = call.name?.trim() || "(unknown tool)";
909
+ const reason =
910
+ call.reason === "missing_name"
911
+ ? "missing tool name"
912
+ : call.reason === "missing_arguments"
913
+ ? "missing arguments"
914
+ : "arguments were invalid JSON";
915
+ return `${name} [${call.id}]: ${reason}`;
916
+ })
917
+ .join("; ");
918
+ return `One or more tool calls were invalid or missing required parameters (${details}). Retry with valid tool names and arguments.`;
919
+ }
920
+
921
+ private buildFailedToolCallFeedback(toolResults: ToolCallRecord[]): string {
922
+ const failed = toolResults.filter((record) => !!record.error);
923
+ if (failed.length === 0) {
924
+ return "";
925
+ }
926
+ const details = failed
927
+ .slice(0, 3)
928
+ .map((record) => {
929
+ const message = String(record.error ?? "unknown tool error")
930
+ .replace(/\s+/g, " ")
931
+ .trim();
932
+ return `${record.name}: ${message}`;
933
+ })
934
+ .join("; ");
935
+ return failed.length > 3
936
+ ? `${details}; +${failed.length - 3} more failed tool call(s)`
937
+ : details;
938
+ }
939
+
940
+ private async recordMistake(input: {
941
+ iteration: number;
942
+ reason: "api_error" | "invalid_tool_call" | "tool_execution_failed";
943
+ details?: string;
944
+ consecutiveMistakes: () => number;
945
+ setConsecutiveMistakes: (value: number) => void;
946
+ }): Promise<boolean> {
947
+ const next = input.consecutiveMistakes() + 1;
948
+ input.setConsecutiveMistakes(next);
949
+ const errorMessage =
950
+ input.details?.trim() || `consecutive mistake (${input.reason})`;
951
+ this.emit({
952
+ type: "error",
953
+ error: new Error(errorMessage),
954
+ recoverable: true,
955
+ iteration: input.iteration,
956
+ });
957
+ this.log("warn", "Recorded consecutive mistake", {
958
+ agentId: this.agentId,
959
+ conversationId: this.conversationStore.getConversationId(),
960
+ runId: this.activeRunId || this.conversationStore.getConversationId(),
961
+ iteration: input.iteration,
962
+ reason: input.reason,
963
+ details: input.details,
964
+ consecutiveMistakes: next,
965
+ maxConsecutiveMistakes: this.config.maxConsecutiveMistakes,
966
+ });
967
+ const maxConsecutiveMistakes = this.config.maxConsecutiveMistakes;
968
+ if (!maxConsecutiveMistakes || next < maxConsecutiveMistakes) {
969
+ return true;
970
+ }
971
+
972
+ const decision = await this.resolveConsecutiveMistakeDecision({
973
+ iteration: input.iteration,
974
+ consecutiveMistakes: next,
975
+ maxConsecutiveMistakes,
976
+ reason: input.reason,
977
+ details: input.details,
978
+ });
979
+ if (decision.action === "continue") {
980
+ const guidance = decision.guidance?.trim();
981
+ if (guidance) {
982
+ this.conversationStore.appendMessage({
983
+ role: "user",
984
+ content: [{ type: "text", text: guidance }],
985
+ });
986
+ }
987
+ input.setConsecutiveMistakes(0);
988
+ return true;
989
+ }
990
+ return false;
991
+ }
992
+
993
+ private async resolveConsecutiveMistakeDecision(input: {
994
+ iteration: number;
995
+ consecutiveMistakes: number;
996
+ maxConsecutiveMistakes: number;
997
+ reason: "api_error" | "invalid_tool_call" | "tool_execution_failed";
998
+ details?: string;
999
+ }): Promise<ConsecutiveMistakeLimitDecision> {
1000
+ const callback = this.config.onConsecutiveMistakeLimitReached;
1001
+ if (!callback) {
1002
+ return {
1003
+ action: "stop",
1004
+ reason: `maximum consecutive mistakes reached (${input.maxConsecutiveMistakes})`,
1005
+ };
1006
+ }
1007
+ try {
1008
+ return await callback(input);
1009
+ } catch (error) {
1010
+ return {
1011
+ action: "stop",
1012
+ reason:
1013
+ error instanceof Error
1014
+ ? error.message
1015
+ : `maximum consecutive mistakes reached (${input.maxConsecutiveMistakes})`,
1016
+ };
1017
+ }
1018
+ }
1019
+
773
1020
  private async ensureExtensionsInitialized(): Promise<void> {
774
1021
  if (this.extensionsInitialized) {
775
1022
  return;
@@ -42,6 +42,7 @@ describe("HookEngine", () => {
42
42
  expect(calls).toEqual(["a-high", "z-low"]);
43
43
  expect(result.control).toEqual({
44
44
  cancel: true,
45
+ review: false,
45
46
  context: "from-a\nfrom-z",
46
47
  overrideInput: { safe: true },
47
48
  });
package/src/index.ts CHANGED
@@ -118,6 +118,8 @@ export {
118
118
  type AgentUsage,
119
119
  AgentUsageSchema,
120
120
  type BasicLogger,
121
+ type ConsecutiveMistakeLimitContext,
122
+ type ConsecutiveMistakeLimitDecision,
121
123
  type ContentBlock,
122
124
  type HookErrorMode,
123
125
  type Message,
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { ToolCallRecord } from "../index.js";
3
+ import { ToolOrchestrator } from "./tool-orchestrator.js";
4
+
5
+ function createOrchestrator(): ToolOrchestrator {
6
+ return new ToolOrchestrator({
7
+ getAgentId: () => "agent-1",
8
+ getConversationId: () => "conversation-1",
9
+ getParentAgentId: () => null,
10
+ emit: () => {},
11
+ dispatchLifecycle: async () => undefined,
12
+ authorizeToolCall: async () => ({ allowed: true }),
13
+ });
14
+ }
15
+
16
+ describe("ToolOrchestrator reminder cadence", () => {
17
+ it("injects reminder only once per interval after threshold", () => {
18
+ const orchestrator = createOrchestrator();
19
+ const results = [
20
+ {
21
+ id: "tool-1",
22
+ name: "example-tool",
23
+ input: {},
24
+ durationMs: 100,
25
+ startedAt: new Date(),
26
+ endedAt: new Date(),
27
+ output: { ok: true },
28
+ },
29
+ ] satisfies ToolCallRecord[];
30
+
31
+ const at50 = orchestrator.buildToolResultMessage(results, 50, {
32
+ afterIterations: 50,
33
+ text: "reminder",
34
+ });
35
+ const at51 = orchestrator.buildToolResultMessage(results, 51, {
36
+ afterIterations: 50,
37
+ text: "reminder",
38
+ });
39
+ const at52 = orchestrator.buildToolResultMessage(results, 52, {
40
+ afterIterations: 50,
41
+ text: "reminder",
42
+ });
43
+ const at101 = orchestrator.buildToolResultMessage(results, 101, {
44
+ afterIterations: 50,
45
+ text: "reminder",
46
+ });
47
+
48
+ expect(at50.content).toHaveLength(1);
49
+ expect(at51.content).toHaveLength(2);
50
+ expect(at52.content).toHaveLength(1);
51
+ expect(at101.content).toHaveLength(2);
52
+ });
53
+ });
@@ -162,7 +162,7 @@ export class ToolOrchestrator {
162
162
  });
163
163
  }
164
164
 
165
- if (reminder.afterIterations > 0 && iteration >= reminder.afterIterations) {
165
+ if (shouldInjectReminder(iteration, reminder.afterIterations)) {
166
166
  content.push({
167
167
  type: "text" as const,
168
168
  text: reminder.text,
@@ -175,3 +175,14 @@ export class ToolOrchestrator {
175
175
  };
176
176
  }
177
177
  }
178
+
179
+ function shouldInjectReminder(
180
+ iteration: number,
181
+ afterIterations: number,
182
+ ): boolean {
183
+ return (
184
+ afterIterations > 0 &&
185
+ iteration > afterIterations &&
186
+ (iteration - 1) % afterIterations === 0
187
+ );
188
+ }
@@ -59,6 +59,7 @@ export class TurnProcessor {
59
59
  string,
60
60
  { name?: string; arguments: string; signature?: string }
61
61
  >();
62
+ const toolCallIdAliases = new Map<string, string>();
62
63
 
63
64
  for await (const chunk of stream) {
64
65
  if (abortSignal.aborted) {
@@ -96,7 +97,11 @@ export class TurnProcessor {
96
97
  });
97
98
  break;
98
99
  case "tool_calls":
99
- this.processToolCallChunk(chunk, pendingToolCallsMap);
100
+ this.processToolCallChunk(
101
+ chunk,
102
+ pendingToolCallsMap,
103
+ toolCallIdAliases,
104
+ );
100
105
  break;
101
106
  case "usage":
102
107
  usage.inputTokens = chunk.inputTokens;
@@ -115,6 +120,7 @@ export class TurnProcessor {
115
120
  }
116
121
 
117
122
  const toolCalls = this.finalizePendingToolCalls(pendingToolCallsMap);
123
+ const invalidToolCalls = this.collectInvalidToolCalls(pendingToolCallsMap);
118
124
  const assistantContent: providers.ContentBlock[] = [];
119
125
 
120
126
  if (text) {
@@ -168,6 +174,7 @@ export class TurnProcessor {
168
174
  text,
169
175
  reasoning: reasoning || undefined,
170
176
  toolCalls,
177
+ invalidToolCalls,
171
178
  usage,
172
179
  truncated,
173
180
  responseId,
@@ -182,15 +189,28 @@ export class TurnProcessor {
182
189
  string,
183
190
  { name?: string; arguments: string; signature?: string }
184
191
  >,
192
+ aliasMap: Map<string, string>,
185
193
  ): void {
186
194
  const { tool_call } = chunk;
187
- const callId =
188
- tool_call.call_id ?? tool_call.function.id ?? `call_${Date.now()}`;
195
+ const functionId = tool_call.function.id;
196
+ const callId = tool_call.call_id;
197
+ const canonicalId =
198
+ (functionId ? aliasMap.get(functionId) : undefined) ??
199
+ (callId ? aliasMap.get(callId) : undefined) ??
200
+ functionId ??
201
+ callId ??
202
+ `call_${Date.now()}`;
203
+ if (functionId) {
204
+ aliasMap.set(functionId, canonicalId);
205
+ }
206
+ if (callId) {
207
+ aliasMap.set(callId, canonicalId);
208
+ }
189
209
 
190
- let pending = pendingMap.get(callId);
210
+ let pending = pendingMap.get(canonicalId);
191
211
  if (!pending) {
192
212
  pending = { name: undefined, arguments: "" };
193
- pendingMap.set(callId, pending);
213
+ pendingMap.set(canonicalId, pending);
194
214
  }
195
215
 
196
216
  if (tool_call.function.name) {
@@ -243,6 +263,37 @@ export class TurnProcessor {
243
263
  return toolCalls;
244
264
  }
245
265
 
266
+ private collectInvalidToolCalls(
267
+ pendingMap: Map<
268
+ string,
269
+ { name?: string; arguments: string; signature?: string }
270
+ >,
271
+ ): Array<{
272
+ id: string;
273
+ name?: string;
274
+ reason: "missing_name" | "missing_arguments" | "invalid_arguments";
275
+ }> {
276
+ const invalid: Array<{
277
+ id: string;
278
+ name?: string;
279
+ reason: "missing_name" | "missing_arguments" | "invalid_arguments";
280
+ }> = [];
281
+ for (const [id, pending] of pendingMap.entries()) {
282
+ if (!pending.name) {
283
+ invalid.push({ id, reason: "missing_name" });
284
+ continue;
285
+ }
286
+ if (!pending.arguments) {
287
+ invalid.push({ id, name: pending.name, reason: "missing_arguments" });
288
+ continue;
289
+ }
290
+ if (this.tryParseJson(pending.arguments) === undefined) {
291
+ invalid.push({ id, name: pending.name, reason: "invalid_arguments" });
292
+ }
293
+ }
294
+ return invalid;
295
+ }
296
+
246
297
  private tryParseJson(value: string): unknown | undefined {
247
298
  const parsed = parseJsonStream(value);
248
299
  return parsed === value ? undefined : parsed;