-
Notifications
You must be signed in to change notification settings - Fork 1
feat(space): enforce per-agent autonomyLevel as a ceiling #2276
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,6 +36,7 @@ import type { | |
| QuestionDraftResponse, | ||
| SpaceGoalStatus, | ||
| SpaceGoalType, | ||
| SpaceAgentAutonomyLevel, | ||
| SpaceLongHorizonAgent, | ||
| SpaceLongHorizonAgentStatus, | ||
| SpaceTask, | ||
|
|
@@ -471,6 +472,15 @@ export interface SpaceAgentToolsConfig { | |
| * writer authorization. | ||
| */ | ||
| myAgentNameAliases?: string[]; | ||
| /** | ||
| * The calling long-term Space agent's id (from `promptProvenance.agentId`). | ||
| * When set and the id resolves to a `SpaceLongHorizonAgent` in this space with | ||
| * a non-null `autonomyLevel`, that level acts as a ceiling on the agent's | ||
| * effective autonomy for its own `space-agent-tools` calls | ||
| * (`min(space.autonomyLevel, agent.autonomyLevel)`). Only long-term agent | ||
| * sessions pass this β coordinators and ad-hoc members are uncapped. | ||
| */ | ||
| myAgentId?: string; | ||
| /** | ||
| * Session ID of the calling agent. Used to stamp `createdBySession` on tasks | ||
| * created via `create_standalone_task`. | ||
|
|
@@ -561,6 +571,7 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) { | |
| getSpaceAutonomyLevel, | ||
| myAgentName, | ||
| myAgentNameAliases, | ||
| myAgentId, | ||
| mySessionId, | ||
| replyRoutingRegistry, | ||
| messageResolver, | ||
|
|
@@ -752,11 +763,75 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) { | |
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Resolves the calling long-term agent's autonomy ceiling, if any. | ||
| * | ||
| * - No calling agent (`myAgentId` unset) β null (uncapped). | ||
| * - Resolves to a `SpaceLongHorizonAgent` in this space β its `autonomyLevel` | ||
| * (null when the operator left it unset β uncapped). | ||
| * - Resolves to a long-horizon agent in another space β null (unreachable in | ||
| * production since sessions are space-scoped; defer to the space level). | ||
| * - Resolves to a worker agent (worker-agent long-term session, which has no | ||
| * autonomy concept) β null (uncapped). | ||
| * - `myAgentId` is set but resolves to no record in either repo (a long-horizon | ||
| * agent deleted while its session is still live) β fail closed at level 1, so | ||
| * a vanished low-trust agent cannot silently become uncapped. | ||
| */ | ||
| function getCallingAgentAutonomyLevel(): SpaceAgentAutonomyLevel | null { | ||
| if (!myAgentId) return null; | ||
| const repo = config.longHorizonAgentRepo; | ||
| if (!repo) return null; | ||
| const agent = repo.getById(myAgentId); | ||
| if (agent) { | ||
| if (agent.spaceId !== spaceId) return null; | ||
| return agent.autonomyLevel ?? null; | ||
| } | ||
| // No long-horizon record. A worker-agent long-term session legitimately has | ||
| // none β uncapped; anything else (e.g. a deleted long-horizon agent) fails closed. | ||
| const workerAgent = spaceAgentManager.getById(myAgentId); | ||
| if (workerAgent && workerAgent.spaceId === spaceId) return null; | ||
| return 1; | ||
| } | ||
|
|
||
| /** | ||
| * Effective autonomy = `min(spaceLevel, agentCeiling)`. The agent ceiling only | ||
| * binds for long-term agent sessions (where `myAgentId` resolves to a | ||
| * `SpaceLongHorizonAgent` with a level). Returns the components so callers can | ||
| * produce precise error messages and audit entries. | ||
| */ | ||
| async function resolveEffectiveAutonomy(): Promise<{ | ||
| level: number; | ||
| spaceLevel: number; | ||
| agentLevel: number | null; | ||
| }> { | ||
| const spaceLevel = getSpaceAutonomyLevel ? await getSpaceAutonomyLevel(spaceId) : 1; | ||
| const agentLevel = getCallingAgentAutonomyLevel(); | ||
| const level = agentLevel == null ? spaceLevel : Math.min(spaceLevel, agentLevel); | ||
| return { level, spaceLevel, agentLevel }; | ||
|
lsm marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /** True when the agent ceiling β not the space level β is the binding constraint. */ | ||
| function isAgentCeilingBinding(spaceLevel: number, agentLevel: number | null): boolean { | ||
| return agentLevel != null && agentLevel < spaceLevel; | ||
| } | ||
|
|
||
| async function requireSessionWriteAutonomy(toolName: string): Promise<void> { | ||
| const level = getSpaceAutonomyLevel ? await getSpaceAutonomyLevel(spaceId) : 1; | ||
| const { level, spaceLevel, agentLevel } = await resolveEffectiveAutonomy(); | ||
| if (level < SESSION_WRITE_AUTONOMY_LEVEL) { | ||
|
lsm marked this conversation as resolved.
|
||
| if (isAgentCeilingBinding(spaceLevel, agentLevel)) { | ||
| logAudit(toolName, { | ||
| blocked: true, | ||
| reason: 'agent_autonomy_ceiling', | ||
| agentLevel, | ||
| spaceLevel, | ||
| required: SESSION_WRITE_AUTONOMY_LEVEL, | ||
| }); | ||
| throw new Error( | ||
| `${toolName} not permitted: agent autonomy ceiling ${agentLevel} (space ${spaceLevel}) < required level ${SESSION_WRITE_AUTONOMY_LEVEL}. Request human approval.` | ||
| ); | ||
| } | ||
| throw new Error( | ||
| `${toolName} not permitted: space autonomy level ${level} < required level ${SESSION_WRITE_AUTONOMY_LEVEL}. Request human approval.` | ||
| `${toolName} not permitted: space autonomy level ${spaceLevel} < required level ${SESSION_WRITE_AUTONOMY_LEVEL}. Request human approval.` | ||
| ); | ||
| } | ||
| } | ||
|
|
@@ -1066,7 +1141,12 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) { | |
| if ( | ||
| mySessionId && | ||
| args.session_id !== mySessionId && | ||
| outboundSenderLevel !== 'space-agent' | ||
| // The coordinator exemption applies only to the real space coordinator | ||
| // (the space:chat session, which carries no calling agent id). A | ||
| // long-term agent that happens to be named "coordinator"/"space-agent" | ||
| // must still pass the autonomy ceiling β otherwise the ceiling is | ||
| // bypassable by renaming via update_agent. | ||
| (outboundSenderLevel !== 'space-agent' || myAgentId) | ||
| ) { | ||
| await requireSessionWriteAutonomy('send_session_message'); | ||
| } | ||
|
|
@@ -1250,6 +1330,10 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) { | |
| handle: uniqueLongHorizonAgentHandle(args.name), | ||
| displayName: args.name, | ||
| instructions: args.custom_prompt ?? args.description ?? '', | ||
| // A restricted caller must not manufacture an uncapped child to bypass | ||
| // its own ceiling; the child inherits the caller's ceiling (null when | ||
| // the caller itself is uncapped). | ||
| autonomyLevel: getCallingAgentAutonomyLevel(), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Although this revision caps newly created children, a level-1 caller can still bypass the ceiling when the space already contains an uncapped or level-5 agent: Useful? React with πΒ / π.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Investigated β the autonomy-escalation half of this is not reachable, and the instruction-rewriting half is real but pre-existing/out-of-scope for this PR. Autonomy escalation (not reachable): the Instruction rewriting (real, but out of scope): true that |
||
| model: args.model ?? null, | ||
| thinkingLevel: args.thinking_level ?? null, | ||
| provider: args.provider ?? null, | ||
|
|
@@ -1296,6 +1380,9 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) { | |
| displayName: name, | ||
| templateKey: template.name, | ||
| instructions: template.customPrompt ?? template.description, | ||
| // Inherit the caller's ceiling (see create_agent) β a restricted caller | ||
| // cannot spawn a more trusted agent from a preset template either. | ||
| autonomyLevel: getCallingAgentAutonomyLevel(), | ||
| model: args.model ?? null, | ||
| provider: args.provider ?? null, | ||
| thinkingLevel: args.thinking_level ?? template.thinkingLevel ?? null, | ||
|
|
@@ -2796,10 +2883,32 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) { | |
| }); | ||
|
|
||
| if (!writerMatches) { | ||
|
lsm marked this conversation as resolved.
|
||
| // Autonomy path: this agent is not in the writers list | ||
| // Autonomy path: this agent is not in the writers list. The effective | ||
| // level is `min(spaceLevel, agentCeiling)` β a low-trust long-term | ||
| // agent is blocked even when the space is permissive. | ||
| const effectiveRequiredLevel = gateDef?.requiredLevel ?? 5; | ||
| const agentLevel = getCallingAgentAutonomyLevel(); | ||
| const spaceLevel = await getSpaceAutonomyLevel(spaceId); | ||
| if (spaceLevel < effectiveRequiredLevel) { | ||
| const effectiveLevel = agentLevel == null ? spaceLevel : Math.min(spaceLevel, agentLevel); | ||
| if (effectiveLevel < effectiveRequiredLevel) { | ||
| if (isAgentCeilingBinding(spaceLevel, agentLevel)) { | ||
| logAudit('approve_gate', { | ||
| blocked: true, | ||
| reason: 'agent_autonomy_ceiling', | ||
| run_id: args.run_id, | ||
| gate_id: args.gate_id, | ||
| agentLevel, | ||
| spaceLevel, | ||
| required: effectiveRequiredLevel, | ||
| }); | ||
| return jsonResult({ | ||
| success: false, | ||
| error: | ||
| `Agent approval blocked: gate "${args.gate_id}" requires autonomy level ` + | ||
| `${effectiveRequiredLevel} but agent autonomy ceiling is ${agentLevel} (space ${spaceLevel}). ` + | ||
| `Increase the agent's autonomy level or request human approval.`, | ||
| }); | ||
| } | ||
| return jsonResult({ | ||
| success: false, | ||
| error: | ||
|
|
@@ -2950,8 +3059,10 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) { | |
| } | ||
|
|
||
| const space = config.spaceManager ? await config.spaceManager.getSpace(spaceId) : null; | ||
| const currentLevel = | ||
| const spaceLevel = | ||
| space?.autonomyLevel ?? (getSpaceAutonomyLevel ? await getSpaceAutonomyLevel(spaceId) : 1); | ||
| const agentLevel = getCallingAgentAutonomyLevel(); | ||
| const currentLevel = agentLevel == null ? spaceLevel : Math.min(spaceLevel, agentLevel); | ||
| let completionAutonomyLevel = 5; | ||
| if (task.workflowRunId) { | ||
| const run = workflowRunRepo.getRun(task.workflowRunId); | ||
|
|
@@ -2964,9 +3075,26 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) { | |
| } | ||
|
|
||
| if (currentLevel < completionAutonomyLevel) { | ||
| if (isAgentCeilingBinding(spaceLevel, agentLevel)) { | ||
| logAudit( | ||
| 'approve_task', | ||
| { | ||
| blocked: true, | ||
| reason: 'agent_autonomy_ceiling', | ||
| agentLevel, | ||
| spaceLevel, | ||
| required: completionAutonomyLevel, | ||
| }, | ||
| args.task_id | ||
| ); | ||
| return jsonResult({ | ||
| success: false, | ||
| error: `approve_task not permitted: agent autonomy ceiling ${agentLevel} (space ${spaceLevel}) < workflow completionAutonomyLevel ${completionAutonomyLevel}. Use submit_for_approval to request human review.`, | ||
| }); | ||
| } | ||
| return jsonResult({ | ||
| success: false, | ||
| error: `approve_task not permitted: space autonomy level ${currentLevel} < workflow completionAutonomyLevel ${completionAutonomyLevel}. Use submit_for_approval to request human review.`, | ||
| error: `approve_task not permitted: space autonomy level ${spaceLevel} < workflow completionAutonomyLevel ${completionAutonomyLevel}. Use submit_for_approval to request human review.`, | ||
| }); | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
ensureLongHorizonAgentSessioncreates a session,SessionLifecycle.createpublishessession.createdbefore this method writespromptProvenance.agentId; the event subscriber therefore classifies the new session as an ad-hoc member and starts attaching a generic, uncappedspace-agent-toolsserver. This explicit capped attachment then races that asynchronous generic attachment, and both merge the same server key, so the generic server commonly finishes last and overwrites the capped one. On a restricted agent's first activation in a permissive space, its approval and session-write calls can consequently run without the new ceiling; persist the provenance beforesession.createdis emitted or otherwise prevent the generic attachment from starting.Useful? React with πΒ / π.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Investigated β no race, because
internalEventBus.publishawaits every subscriber before resolving.internal-event-bus.ts:185(publish) ends withawait Promise.all(handlers)(line :222) β the doc comment states it "awaits every local internal handler." So inensureLongHorizonAgentSession, theawait sessionManager.createSession(...)atspace-runtime-service.ts:591does not return until thesession.createdsubscriber (the genericattachSpaceToolsToMemberSessionpath) has fully resolved β including its internalgetSessionAsync/getSpaceawaits. Only then do lines :610 (provenance write) and :621 (attachLongTermAgentMcpServers, the capped server) run, andmergeRuntimeMcpServersis last-writer-wins for thespace-agent-toolskey, so the capped variant is the final writer. The session isn't active for tool calls untilensureLongHorizonAgentSessionreturns, so there is no window in which the uncapped server is live. No change needed.