Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions packages/core/src/agents/runtime/agent-core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@ import {
type RuntimeContentGeneratorView,
} from './agent-context.js';
import { subagentNameContext } from '../../utils/subagentNameContext.js';
import {
getAgentName,
getTeammateContext,
isTeammate,
runWithTeammateIdentity,
} from '../team/identity.js';
import type { TeammateIdentity } from '../team/types.js';
import type { Config } from '../../config/config.js';
import type {
ModelConfig,
Expand Down Expand Up @@ -213,6 +220,48 @@ describe('AgentCore.runInAgentFrames', () => {
expect(observedAgentId).toBe('agent-123');
});

it('restores the teammate identity for deferred-approval continuations', async () => {
// Regression: a teammate's `send_message`/`task_update` that requires
// confirmation resumes from the UI's async chain, outside the
// teammate identity frame TeamManager established. Before the fix,
// `getAgentName()` returned undefined there and send_message fell back
// to the leader — forging a `from="leader"` envelope and slipping past
// the leader-only `isTeammate()` guard. The respond closure must carry
// the identity captured at emit time back into the resumed tool body.
const core = makeCore('approval-agent');
const teammateIdentity: TeammateIdentity = {
agentId: 'scribe@demo',
agentName: 'scribe',
teamName: 'demo',
isTeamLead: false,
};

let respondClosure: (() => Promise<void>) | undefined;
let observedAgentName: string | undefined;
let observedIsTeammate: boolean | undefined;
const onConfirm = async () => {
observedAgentName = getAgentName();
observedIsTeammate = isTeammate();
};

// Simulate the teammate's loop frame being live at emit time.
await runWithTeammateIdentity(teammateIdentity, async () => {
const inherited = getTeammateContext();
respondClosure = () =>
core.runInAgentFrames(onConfirm, undefined, undefined, inherited);
});

// Teammate frame is gone; jump to a fresh microtask chain to be sure.
expect(getAgentName()).toBeUndefined();
expect(isTeammate()).toBe(false);
await new Promise((resolve) => setImmediate(resolve));

await respondClosure!();

expect(observedAgentName).toBe('scribe');
expect(observedIsTeammate).toBe(true);
});

it("prefers the agent's own view over inheritedView when both are present", async () => {
// Defensive: if a future caller wires both, the agent's explicit view
// wins — we never want a captured snapshot to override the agent's
Expand Down
39 changes: 32 additions & 7 deletions packages/core/src/agents/runtime/agent-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,12 @@ import { matchesMcpPattern } from '../../permissions/rule-parser.js';
import { ToolNames } from '../../tools/tool-names.js';
import { DEFAULT_QWEN_MODEL } from '../../config/models.js';
import { type ContextState, templateString } from './agent-headless.js';
import { isTeammate } from '../team/identity.js';
import {
isTeammate,
getTeammateContext,
runWithTeammateIdentity,
} from '../team/identity.js';
import type { TeammateIdentity } from '../team/types.js';

/**
* Result of a single reasoning loop invocation.
Expand Down Expand Up @@ -572,20 +577,34 @@ export class AgentCore {
* from the parent UI chain, after the subagent's AsyncLocalStorage frame
* has unwound.
*
* `inheritedTeammateIdentity` restores the in-process teammate identity
* frame (`teammateIdentityStore`). Deferred approval needs it for the
* same reason as the others: when a teammate's `send_message` /
* `task_update` resumes from the UI chain, `getAgentName()` would
* otherwise be undefined and the tool would mis-attribute the message
* to the leader (forged `from="leader"` envelope) and slip past the
* leader-only `isTeammate()` guard. No-op on the reasoning-loop path,
* where TeamManager already establishes this frame.
*
* Exposed (rather than inlined twice) so the contract stays testable in
* isolation; see `agent-core.test.ts`.
*/
runInAgentFrames<T>(
fn: () => Promise<T>,
inheritedView?: RuntimeContentGeneratorView,
inheritedAgentId?: string,
inheritedTeammateIdentity?: TeammateIdentity,
): Promise<T> {
return subagentNameContext.run(this.name, () => {
const runWithView = () => this.withRuntimeView(fn, inheritedView);
return inheritedAgentId
? runWithAgentContext(inheritedAgentId, runWithView)
: runWithView();
});
const runInner = () =>
subagentNameContext.run(this.name, () => {
const runWithView = () => this.withRuntimeView(fn, inheritedView);
return inheritedAgentId
? runWithAgentContext(inheritedAgentId, runWithView)
: runWithView();
});
return inheritedTeammateIdentity
? runWithTeammateIdentity(inheritedTeammateIdentity, runInner)
: runInner();
}

/**
Expand Down Expand Up @@ -1243,6 +1262,11 @@ export class AgentCore {
// restore it. See `runInAgentFrames` for the wiring.
const inheritedView = getRuntimeContentGenerator();
const inheritedAgentId = getCurrentAgentId();
// Capture the teammate identity frame too, while the loop
// frame is still live, so the deferred-approval continuation
// can restore it. See `runInAgentFrames` for why this matters
// (mis-attributed `from="leader"` + leader-guard bypass).
const inheritedTeammateIdentity = getTeammateContext();
this.eventEmitter?.emit(AgentEventType.TOOL_WAITING_APPROVAL, {
subagentId: this.subagentId,
round: currentRound,
Expand Down Expand Up @@ -1272,6 +1296,7 @@ export class AgentCore {
() => waiting.confirmationDetails.onConfirm(outcome, payload),
inheritedView,
inheritedAgentId ?? undefined,
inheritedTeammateIdentity,
);
},
timestamp: Date.now(),
Expand Down
Loading