DEV Community

Cover image for I approved the order. Then my Genkit Dart agent forgot its own tool.

I approved the order. Then my Genkit Dart agent forgot its own tool.

My agent asked me "can I order this part?". I said yes. And the answer came back as a failure:

finishReason: FinishReason.failed
finishMessage: Tool order_part not found
Enter fullscreen mode Exit fullscreen mode

The tool was right there. It had been found one step earlier, when the agent stopped to ask for approval. 🫠

I was building a dispatch assistant in pure Dart for a small, made-up field service company (full write-up here). Looking up a work order is free, booking a technician is free, but order_part costs money, so it waits for a person. It took me an evening to find out why the "yes" broke it, and the fix turned into my first PR to Genkit Dart. 🥳

The setup: MCP tools behind an approval

The agent gets its tools from an MCP server and puts toolApproval middleware in front of them. Simplified from my agent:

final mcpTools = await mcpClient.getActiveTools(ai); // includes `work-orders/order_part`

final response = await ai.generate(
  model: model,
  prompt: question,
  tools: mcpTools,
  use: [toolApproval(approved: runWithoutApproval)],
);

if (response.finishReason == FinishReason.interrupted) {
  // Hold the interrupt, show the dispatcher an approve / decline card.
}
Enter fullscreen mode Exit fullscreen mode

runWithoutApproval lists every tool except order_part, so lookups and bookings run straight away.

When the dispatcher approves, I restart the held tool call (held is what I stored when the card was shown):

final resumed = await ai.generate(
  model: model,
  messages: held.messages,
  tools: mcpTools,
  use: [toolApproval(approved: runWithoutApproval)],
  interruptRestart: [held.interrupt.restart({'tool-approved': true})],
);
Enter fullscreen mode Exit fullscreen mode

That second call is the one that failed.

Why the tool vanished only on restart

MCP tools are registered under a namespaced name: work-orders/order_part. But that is not the name the model sees. When Genkit sends tool definitions to the model, toToolDefinition shortens the name to its last segment, order_part, and keeps the full name in metadata.originalName. So the model calls order_part, the name it was given.

Genkit handles that with a second lookup. _resolveTools builds a toolMap keyed by the short name, and _executeTools checks it first:

toolMap['order_part']  →  work-orders/order_part  ✅
registry['order_part'] →  nothing                 ❌
Enter fullscreen mode Exit fullscreen mode

The normal tool loop passes toolMap. The restart path in package:genkit 1.0.1 did not:

// packages/genkit/lib/src/ai/generate.dart, coreGenerate, excerpt (1.0.1)
if (resumeRestart != null) {
  _assertResumable(opts.messages);
  execution = await _executeTools(
    generateRegistry,
    resumeRestart.cast<ToolRequestPart>().toList(),
    c.context,
    cancel: c.cancel,
    middleware: resolvedMiddleware,
  );
}
Enter fullscreen mode Exit fullscreen mode

No toolMap. So _executeTools fell back to a plain registry lookup for order_part, and the registry only knows work-orders/order_part. That is the whole bug.

Same tool call, two lookup paths

In practice: a namespaced MCP tool works fine until you put an approval in front of it and restart it. The dispatcher says yes, and nothing gets ordered.

The fix

Resolve the tools on the restart path too, and pass the map along:

// coreGenerate, excerpt (PR #658)
if (resumeRestart != null) {
  _assertResumable(opts.messages);
  final restartTools = await _resolveTools(
    generateRegistry,
    opts.tools,
    resolvedMiddleware,
  );
  execution = await _executeTools(
    restartTools.registry,
    resumeRestart.cast<ToolRequestPart>().toList(),
    c.context,
    cancel: c.cancel,
    middleware: resolvedMiddleware,
    toolMap: restartTools.toolMap,
  );
}
Enter fullscreen mode Exit fullscreen mode

While I was in there I noticed a second, smaller problem in the same block. When a restarted tool's result went back into the history, only output was copied. A tool that returned content (text or image parts) or metadata lost it on restart. The PR now copies both. That part has no test of its own yet.

The test

The regression test needs no API key. A fake model asks for the short name, the namespaced tool interrupts once, and the restart has to find it. A trimmed excerpt (the fake model stores the tool output it receives in observedToolOutput):

genkit.defineTool(
  name: 'work-orders/order_part',
  description: 'Orders a spare part',
  inputSchema: .map(.string(), .dynamicSchema()),
  fn: (input, context) async {
    toolCallCount++;
    if (toolCallCount == 1) return .interrupt('CONFIRM_ORDER');
    return .response('OrderedPart');
  },
);

final response1 = await genkit.generate(
  model: modelRef(modelName), // fake model, calls `order_part`
  prompt: 'order part',
  toolNames: ['work-orders/order_part'],
);
expect(response1.finishReason, FinishReason.interrupted);

final history = [
  Message(role: Role.user, content: [TextPart(text: 'order part')]),
  response1.message!,
];

final response2 = await genkit.generate(
  model: modelRef(modelName),
  messages: history,
  toolNames: ['work-orders/order_part'],
  interruptRestart: [response1.message!.content.first.toolRequestPart!],
);
expect(toolCallCount, 2);
expect(observedToolOutput, 'OrderedPart');
Enter fullscreen mode Exit fullscreen mode

Before the fix, the restart came back as FinishReason.failed with "Tool order_part not found" (the repro in #657). After it, the tool runs a second time and the model gets OrderedPart back. The full test is in packages/genkit/test/ai/interrupt_test.dart in the PR.

Until it ships

The PR is open: genkit-ai/genkit-dart#658, with the repro in #657. The Google CLA check passes; the repo's Dart and Flutter CI are waiting for a maintainer to approve their first run, which GitHub requires for first-time contributors.

Until then, my workaround is a local tool under the bare name that forwards to the MCP one:

final mcpOrderPart = mcpTools.singleWhere((tool) => tool.name.endsWith('/order_part'));
final orderPart = ai.defineTool<PartOrder, String>(
  name: 'order_part',
  description: mcpOrderPart.description ?? 'Orders a spare part for one work order.',
  inputSchema: PartOrder.$schema,
  fn: (input, _) async => .response('${(await mcpOrderPart.call(input.toJson())).output}'),
);
final tools = [...mcpTools.where((tool) => tool != mcpOrderPart), orderPart];
Enter fullscreen mode Exit fullscreen mode

The MCP server still owns the logic, and the adapter goes away once the fix is released.

The lesson I am keeping: test the resume path, not only the happy path. My agent passed every first run. It only broke after somebody said yes. 🙈

Are you using interrupts or approvals in your agents yet? Does your test suite actually cover what happens after the human clicks approve? 👇

Top comments (0)