@agentforge-io/core 2.2.2 → 2.2.4
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
|
-
|
|
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({
|
|
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 {
|
|
@@ -27,11 +27,23 @@ class ConversationService {
|
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
async addMessage(params) {
|
|
30
|
+
// Coerce empty/whitespace-only content to a sentinel so we don't
|
|
31
|
+
// poison the history with rows that getAnthropicMessages would
|
|
32
|
+
// have to filter (and that Anthropic would 400 on replay).
|
|
33
|
+
// Assistant rows with structured metadata (approval bubbles,
|
|
34
|
+
// blocked-tool cards, etc.) keep their original empty content
|
|
35
|
+
// because `getAnthropicMessages` excludes them indirectly when
|
|
36
|
+
// the metadata kind doesn't map to natural prose.
|
|
37
|
+
const content = typeof params.content === 'string' && params.content.trim().length > 0
|
|
38
|
+
? params.content
|
|
39
|
+
: params.role === 'assistant'
|
|
40
|
+
? '(no response)'
|
|
41
|
+
: params.content;
|
|
30
42
|
const msg = await this.msgRepo.create({
|
|
31
43
|
conversationId: params.conversationId,
|
|
32
44
|
userId: params.userId,
|
|
33
45
|
role: params.role,
|
|
34
|
-
content
|
|
46
|
+
content,
|
|
35
47
|
toolCalls: params.toolCalls,
|
|
36
48
|
usage: params.usage,
|
|
37
49
|
metadata: params.metadata,
|
|
@@ -70,8 +82,18 @@ class ConversationService {
|
|
|
70
82
|
async getAnthropicMessages(conversationId, userId) {
|
|
71
83
|
const conv = await this.loadOwned(conversationId, userId);
|
|
72
84
|
const messages = await this.msgRepo.listForConversation(conv.id, userId);
|
|
85
|
+
// Drop system rows AND any message whose content is empty /
|
|
86
|
+
// whitespace-only. Anthropic rejects empty user/assistant content
|
|
87
|
+
// with a 400 ("messages.N: ... must have non-empty content"),
|
|
88
|
+
// which used to crash the chat the moment the next turn replayed
|
|
89
|
+
// a malformed history row (e.g. a tool-only assistant turn
|
|
90
|
+
// persisted with empty `content`, or a streaming aborted before
|
|
91
|
+
// the user message text settled). Filtering is the cheapest fix —
|
|
92
|
+
// we don't lose semantic information because Anthropic's API
|
|
93
|
+
// wouldn't accept those rows anyway.
|
|
73
94
|
return messages
|
|
74
95
|
.filter((m) => m.role !== 'system')
|
|
96
|
+
.filter((m) => typeof m.content === 'string' && m.content.trim().length > 0)
|
|
75
97
|
.map((m) => ({ role: m.role, content: m.content }));
|
|
76
98
|
}
|
|
77
99
|
async listForUser(userId, options = {}) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentforge-io/core",
|
|
3
|
-
"version": "2.2.
|
|
3
|
+
"version": "2.2.4",
|
|
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",
|