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
5 changes: 5 additions & 0 deletions .changeset/subagent-model-deny-containment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pythoughts/pythinker-code": patch
---

Model permission deny rules now also apply to subagent model overrides coming from agent profiles and from resume or retry, not only to models named in tool arguments; a denied override falls back to the parent agent's model.
5 changes: 5 additions & 0 deletions .changeset/workflow-event-correlation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@pythoughts/pythinker-code": patch
---

Subagent lifecycle events now carry the workflow name on start, completion and failure, and suspension events carry both the workflow run id and name, so clients can correlate every event without caching the spawn event.
42 changes: 42 additions & 0 deletions packages/agent-core/src/agent/permission/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Agent } from '..';
import type { PrepareToolExecutionResult } from '../../loop';
import { createHookIfMatcher } from '../../session/hooks';
import { matchesGlobRuleSubjects, modelRuleSubject } from '../../tools/support/rule-match';
import { matchPermissionRule } from './matches-rule';
import { createPermissionDecisionPolicies } from './policies';
import type {
ApprovalResponse,
Expand Down Expand Up @@ -57,6 +59,40 @@ export class PermissionManager {
};
}

/**
* Whether a deny rule forbids running a subagent on `modelAlias`, asked
* outside the tool-approval path.
*
* Deny rules with an argument pattern fire at approval only when their
* subject appears in the tool arguments. A model resolved after approval —
* a subagent profile's override, or a resume/retry that re-resolves it —
* never comes back through approval, so the spawn path re-checks it here
* against the same rules.
*
* Only rules whose argument pattern targets the `model:` namespace are
* consulted. Approval evaluates every rule against the call's full subject
* set (profile name, plan digest, model); this check sees only the model, so
* a rule keyed on another subject — `Agent(!reviewer)`, a workflow plan
* digest — must not be re-interpreted here: its negation would match any
* model-only subject list and strip an override approval already allowed.
*/
deniesModelOverride(toolName: string, modelAlias: string): boolean {
const subjects = modelRuleSubject(modelAlias);
if (subjects.length === 0) return false;
return this.effectiveRules.some(
(rule) =>
rule.decision === 'deny' &&
matchPermissionRule({
rule,
toolName,
execution: {
matchesRule: (ruleArgs) =>
targetsModelSubject(ruleArgs) && matchesGlobRuleSubjects(ruleArgs, subjects),
},
})?.hasRuleArgs === true,
);
}

setMode(mode: PermissionMode): void {
this.agent.records.logRecord({
type: 'permission.set_mode',
Expand Down Expand Up @@ -368,3 +404,9 @@ export class PermissionManager {
return prefix;
}
}

/** Whether a rule argument pattern (optionally negated) targets the `model:` subject namespace. */
function targetsModelSubject(ruleArgs: string): boolean {
const positive = ruleArgs.startsWith('!') ? ruleArgs.slice(1) : ruleArgs;
return positive.startsWith('model:');
}
21 changes: 19 additions & 2 deletions packages/agent-core/src/session/subagent-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,8 @@ export class SessionSubagentHost {
// All subagent lifecycle events carry the launching tool call id so
// consumers can correlate them to a workflow and drop stale events.
parentToolCallId: event.task.parentToolCallId,
workflowRunId: event.task.workflowRunId,
workflowName: event.task.workflowName,
reason: event.reason,
});
}
Expand Down Expand Up @@ -437,16 +439,28 @@ export class SessionSubagentHost {
* SingleModelProvider) falls back to the parent's model instead of failing at
* generate time. fastMode stays a straight inherit: it is a preference the
* provider layer already drops when the active model cannot serve it.
*
* A `model:` deny rule is re-checked here as well — approval only sees a
* model that was in the tool arguments, so a profile-sourced override (or a
* resume/retry re-resolution) would otherwise ride past `Agent(model:x)` /
* `DynamicWorkflow(model:x)`. Every override lands in this method, making it
* the one containment point; a denied override falls back to the parent's
* model rather than failing the spawn.
*/
private childModelConfig(
parent: Agent,
child: Agent,
profile: ResolvedAgentProfile | undefined,
options: Pick<RunSubagentOptions, 'modelAlias' | 'thinkingLevel'>,
options: Pick<RunSubagentOptions, 'modelAlias' | 'thinkingLevel' | 'workflowRunId'>,
): { modelAlias: string | undefined; thinkingLevel: string | undefined; fastMode: boolean } {
const requested = options.modelAlias ?? profile?.model;
const modelAlias =
requested !== undefined && child.config.canResolveModel(requested)
requested !== undefined &&
child.config.canResolveModel(requested) &&
!parent.permission.deniesModelOverride(
options.workflowRunId === undefined ? 'Agent' : 'DynamicWorkflow',
requested,
)
? requested
: parent.config.modelAlias;
return {
Expand Down Expand Up @@ -562,6 +576,7 @@ export class SessionSubagentHost {
subagentId: childId,
parentToolCallId: options.parentToolCallId,
workflowRunId: options.workflowRunId,
workflowName: options.workflowName,
resultSummary: result,
usage,
contextTokens: child.context.tokenCount,
Expand Down Expand Up @@ -712,6 +727,7 @@ export class SessionSubagentHost {
subagentId: childId,
parentToolCallId: options.parentToolCallId,
workflowRunId: options.workflowRunId,
workflowName: options.workflowName,
});
}

Expand All @@ -727,6 +743,7 @@ export class SessionSubagentHost {
subagentId: childId,
parentToolCallId: options.parentToolCallId,
workflowRunId: options.workflowRunId,
workflowName: options.workflowName,
error: error instanceof Error ? error.message : String(error),
});
}
Expand Down
Loading
Loading