@agentforge-io/core 2.3.0 → 3.0.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.
@@ -379,6 +379,11 @@ class AgentService {
379
379
  const filter = params.overrides?.extraToolsFilter;
380
380
  const fromConnectors = filter && resolvedExtras ? filter(resolvedExtras) : resolvedExtras;
381
381
  const extraTools = mergeExtraTools(params.overrides?.extraTools, fromConnectors);
382
+ // Hoisted accumulators so the post-loop persistence (after the
383
+ // try) can see the final list. Defined here, populated inside
384
+ // the for-await loop below.
385
+ const toolCallStartByUseId = new Map();
386
+ const accumulatedToolCalls = [];
382
387
  try {
383
388
  // Team orchestrators route through OrchestratorService.stream()
384
389
  // so the synthetic `delegate_to_*` tools the orchestrator was
@@ -403,11 +408,56 @@ class AgentService {
403
408
  messageId: 'streaming',
404
409
  agent: { timezone: agent.timezone },
405
410
  }, { ...(params.overrides ?? {}), extraTools });
411
+ // Accumulate tool_use / tool_result chunks during streaming so
412
+ // we can persist them on the assistant message row (line ~700).
413
+ // Without this, `getHistory` returns the assistant's text but
414
+ // loses the tool calls — which means clients that render
415
+ // proposal cards (Recording Assist) or generic tool rows from
416
+ // history have nothing to rehydrate. FIFO matching mirrors the
417
+ // runner's heuristic so the shape stays consistent whether the
418
+ // turn streamed or used `run()`.
406
419
  for await (const chunk of stream) {
407
420
  if (chunk.type === 'text_delta')
408
421
  fullContent += chunk.delta;
409
422
  if (chunk.type === 'usage')
410
423
  finalUsage = chunk.usage;
424
+ if (chunk.type === 'tool_use_start') {
425
+ toolCallStartByUseId.set(chunk.toolUseId, {
426
+ name: chunk.toolName,
427
+ start: Date.now(),
428
+ });
429
+ }
430
+ if (chunk.type === 'tool_result') {
431
+ // Match by toolUseId when present; otherwise FIFO on toolName
432
+ // (matches AgentRunner.run's heuristic). Older runners that
433
+ // don't emit `toolUseId` on result chunks still produce a
434
+ // usable record.
435
+ let entry = null;
436
+ const useId = chunk.toolUseId;
437
+ if (useId && toolCallStartByUseId.has(useId)) {
438
+ const v = toolCallStartByUseId.get(useId);
439
+ entry = { id: useId, name: v.name, start: v.start };
440
+ toolCallStartByUseId.delete(useId);
441
+ }
442
+ else {
443
+ const fifo = Array.from(toolCallStartByUseId.entries()).find(([, v]) => v.name === chunk.toolName);
444
+ if (fifo) {
445
+ entry = { id: fifo[0], name: fifo[1].name, start: fifo[1].start };
446
+ toolCallStartByUseId.delete(fifo[0]);
447
+ }
448
+ }
449
+ if (entry) {
450
+ accumulatedToolCalls.push({
451
+ toolName: entry.name,
452
+ toolUseId: entry.id,
453
+ input: {},
454
+ output: typeof chunk.result === 'string'
455
+ ? chunk.result
456
+ : JSON.stringify(chunk.result),
457
+ durationMs: Date.now() - entry.start,
458
+ });
459
+ }
460
+ }
411
461
  yield chunk;
412
462
  }
413
463
  }
@@ -507,6 +557,12 @@ class AgentService {
507
557
  role: 'assistant',
508
558
  content: fullContent,
509
559
  usage: finalUsage,
560
+ // Persist the accumulated tool calls so `getHistory` can
561
+ // surface them on reload — without this, proposal cards
562
+ // (Recording Assist) and other tool-result-driven UI
563
+ // disappear after refresh. Empty arrays drop to undefined
564
+ // so we don't pollute the column with `[]`.
565
+ toolCalls: accumulatedToolCalls.length > 0 ? accumulatedToolCalls : undefined,
510
566
  });
511
567
  const now = new Date();
512
568
  await this.dispatchUsage({
@@ -142,6 +142,11 @@ class OrchestratorService {
142
142
  return;
143
143
  }
144
144
  this.logger.debug(`Streaming orchestrator "${agentId}" with subagents: ${agent.subAgents.join(', ')}`);
145
+ // Pre-resolve all sub-agents so `buildDelegationTools` sees their
146
+ // name/description/slug — without this, the agentsMap only has the
147
+ // orchestrator and the tool-name builder falls back to the UUID
148
+ // form, defeating the slug→name match the model relies on.
149
+ await Promise.all(agent.subAgents.map((id) => this.resolveAgentDynamic(id)));
145
150
  const delegationTools = this.buildDelegationTools(agent);
146
151
  const model = agent.model ?? this.anthropicConfig.defaultModel ?? 'claude-opus-4-6';
147
152
  const maxTokens = agent.maxTokens ?? this.anthropicConfig.defaultMaxTokens ?? 4096;
@@ -213,8 +218,14 @@ class OrchestratorService {
213
218
  if (block.type !== 'tool_use')
214
219
  continue;
215
220
  const delegateTool = delegationTools.find((t) => t.name === block.name);
216
- if (!delegateTool)
221
+ if (!delegateTool) {
222
+ // The model called a tool name we didn't expose. Log loudly
223
+ // so we can spot drift between the prompt's slug references
224
+ // and the actual tool surface.
225
+ this.logger.warn(`Orchestrator "${agent.id}" called unknown tool "${block.name}". ` +
226
+ `Available tools: ${delegationTools.map((t) => t.name).join(', ')}`);
217
227
  continue;
228
+ }
218
229
  const { task } = block.input;
219
230
  const { subAgentId } = delegateTool;
220
231
  const subAgent = await this.resolveAgentDynamic(subAgentId);
@@ -333,6 +344,9 @@ class OrchestratorService {
333
344
  }
334
345
  async runOrchestratorLoop(orchestrator, messages, context) {
335
346
  const delegations = [];
347
+ // Pre-resolve subagents so buildDelegationTools sees their
348
+ // slug/name/description (same fix as the stream path).
349
+ await Promise.all((orchestrator.subAgents ?? []).map((id) => this.resolveAgentDynamic(id)));
336
350
  const delegationTools = this.buildDelegationTools(orchestrator);
337
351
  const model = orchestrator.model ?? this.anthropicConfig.defaultModel ?? 'claude-opus-4-6';
338
352
  const maxTokens = orchestrator.maxTokens ?? this.anthropicConfig.defaultMaxTokens ?? 4096;
@@ -439,13 +453,43 @@ class OrchestratorService {
439
453
  }
440
454
  // ─── Helpers ───────────────────────────────────────────────────────────────
441
455
  buildDelegationTools(orchestrator) {
456
+ // First pass: collect the slug/uuid-name candidates so we can
457
+ // detect collisions across the team. Anthropic requires `tool.name`
458
+ // to be unique within a single request; two subagents with the
459
+ // same slug (rare but legal at the platform layer in different
460
+ // tenant scopes) must not collapse into one tool.
461
+ const namesUsed = new Set();
442
462
  return (orchestrator.subAgents ?? []).map((subAgentId) => {
443
463
  const sub = this.agentsMap.get(subAgentId);
464
+ const subSlug = sub?.slug;
465
+ // Tool name strategy: prefer `delegate_to_<slug>` because the model
466
+ // already SEES `@<slug>` in its system prompt; matching it to the
467
+ // tool name eliminates the two-namespace gap that caused
468
+ // wrong-tool calls. Fall back to a sanitized UUID-derived name
469
+ // when no slug is available or when the slug name is already
470
+ // taken by an earlier subagent in this build pass.
471
+ const slugName = subSlug
472
+ ? `delegate_to_${subSlug.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 50)}`
473
+ : null;
474
+ const uuidName = `delegate_to_${subAgentId.replace(/[^a-zA-Z0-9]/g, '_')}`;
475
+ let name;
476
+ if (slugName && !namesUsed.has(slugName)) {
477
+ name = slugName;
478
+ }
479
+ else {
480
+ name = uuidName;
481
+ }
482
+ namesUsed.add(name);
483
+ // Description still calls out the slug explicitly — belt and
484
+ // suspenders against models that pattern-match on text more
485
+ // than on tool names.
444
486
  const description = sub
445
- ? `Delegate a task to the "${sub.name}" specialist agent. ${sub.description ?? ''}`
487
+ ? `Delegate a task to the "${sub.name}" specialist agent` +
488
+ (subSlug ? ` (mention slug: @${subSlug})` : '') +
489
+ `. ${sub.description ?? ''}`
446
490
  : `Delegate a task to subagent "${subAgentId}"`;
447
491
  return {
448
- name: `delegate_to_${subAgentId.replace(/[^a-zA-Z0-9]/g, '_')}`,
492
+ name,
449
493
  description,
450
494
  subAgentId,
451
495
  inputSchema: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agentforge-io/core",
3
- "version": "2.3.0",
4
- "description": "Framework-free AI runtime SDK. Owns: agent loop (pluggable LLM provider — Anthropic by default, LangChain-backed providers as drop-ins), conversations, tools, streaming, agent-job queue, SdkHooks. Identity, billing, infra (email/uploads/secrets) live in the host's modules not here.",
3
+ "version": "3.0.0",
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 \u2014 not here.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
@@ -33,4 +33,4 @@
33
33
  "tsx": "^4.19.0",
34
34
  "typescript": "^5.0.0"
35
35
  }
36
- }
36
+ }