Describe the Bug
In @flue/runtime@2.0.3, the scratch conversation opened by harness.prompt() inside a
harness: true tool receives both:
- the parent agent's entire tool registry — including every other
harness: true tool, and
- the parent agent's system prompt, verbatim.
Each harness invocation adds a delegation level, so a phase tool whose conversation calls
another phase tool nests until Maximum delegation depth (4) exceeded.
The trigger is the combination with (2). When an agent's instructions are a pipeline that
names its tools imperatively — "1. Call research_module… 2. For EACH item call
research_item" — every phase tool inherits those instructions as its own marching orders and
starts running the pipeline from the top.
Observed in a real multi-phase documentation agent: 23 depth errors in one run, the first phase
tool invoked 5 times instead of once, and the later phases (review, style-check) never
executed at all — so the agent produced five documents that were never reviewed. It reported
success. Separately, one phase re-ran the entire pipeline inside itself for 36 minutes.
This is adjacent to #503 / #522 but a different mechanism, and it is present in 2.0.3:
#503's phrase "pure re-execution, not decomposition" describes this precisely; the door is just
different, and it is still open.
Expected Behavior
At minimum, a harness: true tool should not be able to re-enter itself. A tool calling
itself from inside its own harness conversation is never intentional and can be rejected at
dispatch.
Ideally, a harness: true tool's conversation would not see the agent's other harness tools by
default, since it exists to do one scoped piece of work.
I am deliberately not proposing per-operation tool scoping (allowedTools /
excludeTools). Per the maintainer reply on #522, the serialized tools block is kept static to
preserve provider prompt caching, and roster-dependent presence flips would bust that cache.
Rejecting self-re-entry at dispatch needs no schema change and no change to the tools block, so
it should not affect caching.
Steps to Reproduce
Minimal repro, in a project scaffolded with npx @flue/cli init repro --target node and pinned
to @flue/runtime@2.0.3, @flue/cli@2.0.3. Model anthropic/claude-haiku-4-5,
thinkingLevel: 'off'. Every call is sequential — no concurrency anywhere.
A. One tool, calls itself → nests to 4, then throws
'use agent';
import { defineTool, useModel, useTool } from '@flue/runtime';
import * as v from 'valibot';
const phaseA = defineTool({
name: 'phase_a',
description: 'Run phase A. Takes the current nesting depth.',
harness: true,
input: v.object({ depth: v.number() }),
output: v.object({ note: v.string() }),
async run({ harness, data }) {
const reply = await harness.prompt(
`You are inside the phase_a tool at depth ${data.depth}. ` +
`Call the phase_a tool exactly once with depth ${data.depth + 1}. ` +
`Then report, in the note field, what the tool returned or what error it gave.`,
{ result: v.object({ note: v.string() }) },
);
return { output: reply.data };
},
});
export function SelfCall() {
useModel('anthropic/claude-haiku-4-5', { thinkingLevel: 'off' });
useTool(phaseA);
return `Call the phase_a tool exactly once with depth 1. Then reply with one short sentence.`;
}
tool start phase_a {"depth":1} ← from root
tool start phase_a {"depth":2} ← from inside phase_a's own harness conversation
tool start phase_a {"depth":3}
tool start phase_a {"depth":4}
tool start phase_a {"depth":5}
*** Maximum delegation depth (4) exceeded.
B / C — the same framework behaviour is harmless with ordinary instructions
- B: two
harness: true tools plus a declared subagent, each tool's prompt delegating via
task, short instructions. → 0 depth errors, each tool called once, clean run.
- C: three tools, a linear numbered pipeline. → 0 depth errors, clean run.
So the capability alone does not cause failure. C also probes the inheritance directly — its
tool asks its own harness conversation to echo the first line of its system prompt, and gets:
"systemPromptFirstLine": "You write reference documentation for a module."
which is the agent's instruction text.
D — the reproduction: pipeline instructions that name the tools
Same as C plus a per-item loop naming the phase tools, and two similarly-named research tools:
export function SelfRefInstructions() {
useModel('anthropic/claude-haiku-4-5', { thinkingLevel: 'off' });
useTool(researchModule); // harness: true, delegates to `worker` via task
useTool(researchItem); // harness: true
useTool(writeItemPage); // harness: true
useSubagent(worker);
return [
'You write hierarchical reference documentation for a module.',
'',
'1. **Research the module.** Call `research_module` with the module name. It returns the',
' module findings and the list of items.',
'',
'2. **Write per-item subpages.** For EACH item returned in step 1:',
' a. Call `research_item` with the item name for its findings.',
' b. Call `write_item_page` with that item and those findings.',
' Do not skip an item. Every item needs both steps.',
'',
'The module reference is not complete until every item has a subpage.',
].join('\n');
}
Run: flue run src/agents/self-ref-instructions.ts -m "Write the module reference for: optics."
research_module 5 ← should be 1; re-enters itself from its own harness conversation
research_item 0 ← never reached
write_item_page 0 ← never reached
give_up 4
depth errors 10
The model's own give_up reason:
"Maximum delegation depth (4) has been exceeded, preventing me from delegating to the worker
agent or conducting direct research on the optics module."
The pipeline never progresses past step 1. In the real application the phases that never ran
were the review and writing-style gates, which is how unreviewed output shipped.
Ruled out
- Concurrency. Every variant above is strictly sequential and D still fails. Depth is
per-branch and never accumulates (the only writes to delegationDepth are constructors).
tools: []. Adding it to the harness.prompt call changes nothing — identical recursion,
since the option is additive. Included as a variant in the repro.
Additional observability notes
These are independent of the bug but each cost hours of misdiagnosis, and all are cheap:
- Nesting is invisible in the event stream. Every nested harness conversation reports
harness=default session=default taskId=- — byte-identical to the root agent's own turns.
Depth 1 and depth 4 cannot be distinguished; no event carries a depth or invocation id. This
is why the failure initially looked like a hang rather than recursion.
- A depth rejection emits no
task_start. The guard throws before the task session is
created, so a failed delegation is invisible in the delegation event log and appears only as
the tool's error result. Counting task_start understates failures.
durationMs is batch-max for parallel tool calls. Four calls that really took 59 s /
101 s / 120 s / 484 s all reported an identical 554306 ms, because timing runs from batch
dispatch to batch collection. It cannot be used to find a slow call — and it made a
recursion look like a 77-minute hang.
- No timestamps in verbose output. Reconstructing the real run's timeline required solving
a difference-constraint system over matched start/end pairs.
Environment
@flue/runtime@2.0.3, @flue/cli@2.0.3 (exact pins)
- Node target,
flue run, no server
anthropic/claude-haiku-4-5
- Project scaffolded with
npx @flue/cli init … --target node
Happy to push the full four-variant repro to a public repo if that helps.
Describe the Bug
In
@flue/runtime@2.0.3, the scratch conversation opened byharness.prompt()inside aharness: truetool receives both:harness: truetool, andEach harness invocation adds a delegation level, so a phase tool whose conversation calls
another phase tool nests until
Maximum delegation depth (4) exceeded.The trigger is the combination with (2). When an agent's instructions are a pipeline that
names its tools imperatively — "1. Call
research_module… 2. For EACH item callresearch_item" — every phase tool inherits those instructions as its own marching orders andstarts running the pipeline from the top.
Observed in a real multi-phase documentation agent: 23 depth errors in one run, the first phase
tool invoked 5 times instead of once, and the later phases (review, style-check) never
executed at all — so the agent produced five documents that were never reviewed. It reported
success. Separately, one phase re-ran the entire pipeline inside itself for 36 minutes.
This is adjacent to #503 / #522 but a different mechanism, and it is present in 2.0.3:
tasktool is offered (and works) when no subagents are declared, enabling recursive self-delegation #503 / Empty subagent roster still exposes callable task tool #522 concern the built-intasktool being callable with an empty subagent roster.harness: truetools being visible to each other, with subagentscorrectly declared and
taskworking as intended.#503's phrase "pure re-execution, not decomposition" describes this precisely; the door is just
different, and it is still open.
Expected Behavior
At minimum, a
harness: truetool should not be able to re-enter itself. A tool callingitself from inside its own harness conversation is never intentional and can be rejected at
dispatch.
Ideally, a
harness: truetool's conversation would not see the agent's other harness tools bydefault, since it exists to do one scoped piece of work.
I am deliberately not proposing per-operation tool scoping (
allowedTools/excludeTools). Per the maintainer reply on #522, the serialized tools block is kept static topreserve provider prompt caching, and roster-dependent presence flips would bust that cache.
Rejecting self-re-entry at dispatch needs no schema change and no change to the tools block, so
it should not affect caching.
Steps to Reproduce
Minimal repro, in a project scaffolded with
npx @flue/cli init repro --target nodeand pinnedto
@flue/runtime@2.0.3,@flue/cli@2.0.3. Modelanthropic/claude-haiku-4-5,thinkingLevel: 'off'. Every call is sequential — no concurrency anywhere.A. One tool, calls itself → nests to 4, then throws
B / C — the same framework behaviour is harmless with ordinary instructions
harness: truetools plus a declared subagent, each tool's prompt delegating viatask, short instructions. → 0 depth errors, each tool called once, clean run.So the capability alone does not cause failure. C also probes the inheritance directly — its
tool asks its own harness conversation to echo the first line of its system prompt, and gets:
which is the agent's instruction text.
D — the reproduction: pipeline instructions that name the tools
Same as C plus a per-item loop naming the phase tools, and two similarly-named research tools:
Run:
flue run src/agents/self-ref-instructions.ts -m "Write the module reference for: optics."The model's own
give_upreason:The pipeline never progresses past step 1. In the real application the phases that never ran
were the review and writing-style gates, which is how unreviewed output shipped.
Ruled out
per-branch and never accumulates (the only writes to
delegationDepthare constructors).tools: []. Adding it to theharness.promptcall changes nothing — identical recursion,since the option is additive. Included as a variant in the repro.
Additional observability notes
These are independent of the bug but each cost hours of misdiagnosis, and all are cheap:
harness=default session=default taskId=-— byte-identical to the root agent's own turns.Depth 1 and depth 4 cannot be distinguished; no event carries a depth or invocation id. This
is why the failure initially looked like a hang rather than recursion.
task_start. The guard throws before the task session iscreated, so a failed delegation is invisible in the delegation event log and appears only as
the tool's error result. Counting
task_startunderstates failures.durationMsis batch-max for parallel tool calls. Four calls that really took 59 s /101 s / 120 s / 484 s all reported an identical
554306 ms, because timing runs from batchdispatch to batch collection. It cannot be used to find a slow call — and it made a
recursion look like a 77-minute hang.
a difference-constraint system over matched start/end pairs.
Environment
@flue/runtime@2.0.3,@flue/cli@2.0.3(exact pins)flue run, no serveranthropic/claude-haiku-4-5npx @flue/cli init … --target nodeHappy to push the full four-variant repro to a public repo if that helps.