Skip to content

Harness tools are visible to each other and inherit the agent's system prompt, causing workflow re-entry (2.0.3) #561

Description

@khajavi

Describe the Bug

In @flue/runtime@2.0.3, the scratch conversation opened by harness.prompt() inside a
harness: true tool receives both:

  1. the parent agent's entire tool registry — including every other harness: true tool, and
  2. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions