@agentforge-io/core 2.2.1 → 2.2.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.
@@ -172,11 +172,24 @@ class AgentRunnerService {
172
172
  toolResults.push({
173
173
  type: 'tool_result',
174
174
  tool_use_id: block.id,
175
- content: output,
175
+ // Sentinel keeps Anthropic happy when a tool produced
176
+ // no string output (e.g. a mutation that returned void).
177
+ content: output || '(tool completed with no output)',
176
178
  is_error: !!error,
177
179
  });
178
180
  }
179
181
  }
182
+ // Same defensive break as the stream path — if tool_use was
183
+ // signalled but we resolved zero tool calls, don't append
184
+ // an empty user message.
185
+ if (toolResults.length === 0) {
186
+ this.logger.warn(`Agent "${agent.id}" reported tool_use but emitted no resolvable tool calls. Closing the turn.`);
187
+ finalContent = response.content
188
+ .filter((b) => b.type === 'text')
189
+ .map((b) => b.text)
190
+ .join('');
191
+ break;
192
+ }
180
193
  currentMessages = [...currentMessages, { role: 'user', content: toolResults }];
181
194
  }
182
195
  else {
@@ -295,9 +308,27 @@ class AgentRunnerService {
295
308
  output = `Error: ${err instanceof Error ? err.message : String(err)}`;
296
309
  }
297
310
  yield { type: 'tool_result', toolName: block.name, result: output };
298
- toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: output });
311
+ toolResults.push({
312
+ type: 'tool_result',
313
+ tool_use_id: block.id,
314
+ // Anthropic rejects empty tool_result content (part of
315
+ // the "messages.N: user messages must have non-empty
316
+ // content" 400). When the tool returned no string,
317
+ // substitute a sentinel so the next planning step still
318
+ // sees a coherent transcript.
319
+ content: output || '(tool completed with no output)',
320
+ });
299
321
  }
300
322
  }
323
+ // Defensive: if the model said `tool_use` but emitted zero
324
+ // tool_use blocks (or all were filtered for unknown names),
325
+ // appending `{role:'user', content:[]}` triggers the same
326
+ // Anthropic 400. Break and let whatever text the model
327
+ // already produced stand as the final answer.
328
+ if (toolResults.length === 0) {
329
+ this.logger.warn(`Agent "${agent.id}" reported tool_use but emitted no resolvable tool calls. Closing the turn.`);
330
+ break;
331
+ }
301
332
  currentMessages = [...currentMessages, { role: 'user', content: toolResults }];
302
333
  }
303
334
  else {
@@ -310,6 +310,18 @@ class OrchestratorService {
310
310
  content: assembled || '(member completed silently — no textual response)',
311
311
  });
312
312
  }
313
+ // Defensive: when the model returned `tool_use` as stop_reason
314
+ // but every emitted block was a non-tool_use block (or pointed
315
+ // at a delegate_to_* whose subAgent is no longer in our list),
316
+ // `toolResults` ends up empty. Appending `{role:'user', content:
317
+ // []}` makes Anthropic reject the next turn with "messages.N:
318
+ // user messages must have non-empty content" (400). Break out
319
+ // gracefully and let whatever text the model already emitted
320
+ // serve as the final answer.
321
+ if (toolResults.length === 0) {
322
+ this.logger.warn(`Orchestrator "${agent.id}" reported tool_use but produced no resolvable delegation tool calls. Closing the turn.`);
323
+ break;
324
+ }
313
325
  // Feed the tool results back into the orchestrator's next loop.
314
326
  currentMessages = [
315
327
  ...currentMessages,
@@ -386,9 +398,23 @@ class OrchestratorService {
386
398
  toolResults.push({
387
399
  type: 'tool_result',
388
400
  tool_use_id: block.id,
389
- content: subResult.content,
401
+ // Anthropic rejects empty tool_result content as part of
402
+ // the "non-empty content" rule. Substitute a sentinel
403
+ // when the sub-agent finished without text.
404
+ content: subResult.content || '(member completed silently — no textual response)',
390
405
  });
391
406
  }
407
+ // Same defensive break as the stream path — if tool_use was
408
+ // signalled but we resolved zero delegation calls, don't
409
+ // append `{role:'user', content:[]}` (Anthropic 400).
410
+ if (toolResults.length === 0) {
411
+ this.logger.warn(`Orchestrator "${orchestrator.id}" reported tool_use but produced no resolvable delegation tool calls. Closing the turn.`);
412
+ finalContent = response.content
413
+ .filter((b) => b.type === 'text')
414
+ .map((b) => b.text)
415
+ .join('');
416
+ break;
417
+ }
392
418
  currentMessages = [...currentMessages, { role: 'user', content: toolResults }];
393
419
  }
394
420
  else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/core",
3
- "version": "2.2.1",
3
+ "version": "2.2.3",
4
4
  "description": "Framework-free AI runtime SDK. Owns: agent loop (Anthropic), conversations, tools, streaming, agent-job queue, SdkHooks. Identity, billing, infra (email/uploads/secrets) live in the host's modules — not here.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",