Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
27 changes: 22 additions & 5 deletions packages/daemon/src/lib/space/runtime/space-runtime-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,9 +618,15 @@ export class SpaceRuntimeService {
},
},
});
this.attachLongTermAgentMcpServers(session, space, agent.displayName, sessionId, null, [
`@${agent.handle}`,
]);
this.attachLongTermAgentMcpServers(
session,
space,
agent.displayName,
sessionId,
null,
agentId,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Attach the ceiling before publishing new agent sessions

When ensureLongHorizonAgentSession creates a session, SessionLifecycle.create publishes session.created before this method writes promptProvenance.agentId; the event subscriber therefore classifies the new session as an ad-hoc member and starts attaching a generic, uncapped space-agent-tools server. 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 before session.created is emitted or otherwise prevent the generic attachment from starting.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

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.publish awaits every subscriber before resolving.

internal-event-bus.ts:185 (publish) ends with await Promise.all(handlers) (line :222) β€” the doc comment states it "awaits every local internal handler." So in ensureLongHorizonAgentSession, the await sessionManager.createSession(...) at space-runtime-service.ts:591 does not return until the session.created subscriber (the generic attachSpaceToolsToMemberSession path) has fully resolved β€” including its internal getSessionAsync/getSpace awaits. Only then do lines :610 (provenance write) and :621 (attachLongTermAgentMcpServers, the capped server) run, and mergeRuntimeMcpServers is last-writer-wins for the space-agent-tools key, so the capped variant is the final writer. The session isn't active for tool calls until ensureLongHorizonAgentSession returns, so there is no window in which the uncapped server is live. No change needed.

[`@${agent.handle}`]
);
return session;
}

Expand Down Expand Up @@ -706,7 +712,7 @@ export class SpaceRuntimeService {
await session.resetQuery({ restartQuery: true });
}
if (created || this.missingLongTermAgentMcpServers(session)) {
this.attachLongTermAgentMcpServers(session, space, agent.name, sessionId, agent);
this.attachLongTermAgentMcpServers(session, space, agent.name, sessionId, agent, agentId);
}
return session;
}
Expand Down Expand Up @@ -740,7 +746,14 @@ export class SpaceRuntimeService {
}
const agentName =
session.metadata.promptProvenance?.agentName ?? persistedAgent?.name ?? 'Space Agent';
this.attachLongTermAgentMcpServers(agentSession, space, agentName, session.id, persistedAgent);
this.attachLongTermAgentMcpServers(
agentSession,
space,
agentName,
session.id,
persistedAgent,
agentId
);
agentSession.onMissingMemberSpaceMcpServers = async (_sessionId, missing) => {
log.warn(
`Long-term Space agent session ${session.id} missing MCP servers [${missing.join(', ')}]; re-attaching space-agent-tools before query start`
Expand All @@ -762,6 +775,7 @@ export class SpaceRuntimeService {
agentName: string,
sessionId: string,
agent: SpaceWorkerAgent | null,
agentId: string | null,
agentHandleAliases?: string[]
): void {
const mcpServers: Record<string, McpServerConfig> = {
Expand All @@ -770,6 +784,7 @@ export class SpaceRuntimeService {
agentName,
sessionId,
agent,
agentId,
agentHandleAliases
) as unknown as McpServerConfig,
};
Expand Down Expand Up @@ -814,6 +829,7 @@ export class SpaceRuntimeService {
agentName: string,
sessionId: string,
agent: SpaceWorkerAgent | null,
agentId: string | null,
agentHandleAliases?: string[]
) {
const agents = this.config.spaceAgentManager.listBySpaceId(space.id);
Expand Down Expand Up @@ -852,6 +868,7 @@ export class SpaceRuntimeService {
},
myAgentName: agentName,
myAgentNameAliases: aliases,
myAgentId: agentId ?? undefined,
mySessionId: sessionId,
auditLogRepo: this.auditLogRepo,
scheduleService: this.config.scheduleService,
Expand Down
109 changes: 103 additions & 6 deletions packages/daemon/src/lib/space/tools/space-agent-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,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`.
Expand Down Expand Up @@ -561,6 +570,7 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) {
getSpaceAutonomyLevel,
myAgentName,
myAgentNameAliases,
myAgentId,
mySessionId,
replyRoutingRegistry,
messageResolver,
Expand Down Expand Up @@ -752,11 +762,60 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) {
});
}

/**
* Resolves the calling long-term agent's autonomy ceiling, if any.
* Returns null when there is no calling agent (`myAgentId` unset), the agent
* isn't a `SpaceLongHorizonAgent` in this space (e.g. a worker agent), or it
* has no `autonomyLevel` set β€” meaning no ceiling applies.
*/
function getCallingAgentAutonomyLevel(): number | null {
if (!myAgentId) return null;
const repo = config.longHorizonAgentRepo;
if (!repo) return null;
const agent = repo.getById(myAgentId);
if (!agent || agent.spaceId !== spaceId) return null;
Comment thread
lsm marked this conversation as resolved.
Outdated
return agent.autonomyLevel ?? null;
Comment thread
lsm marked this conversation as resolved.
Outdated
}

/**
* 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 };
Comment thread
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) {
Comment thread
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.`
);
}
}
Expand Down Expand Up @@ -2796,10 +2855,32 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) {
});

if (!writerMatches) {
Comment thread
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:
Expand Down Expand Up @@ -2950,8 +3031,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);
Expand All @@ -2964,9 +3047,23 @@ export function createSpaceAgentToolHandlers(config: SpaceAgentToolsConfig) {
}

if (currentLevel < completionAutonomyLevel) {
if (isAgentCeilingBinding(spaceLevel, agentLevel)) {
logAudit('approve_task', {
blocked: true,
reason: 'agent_autonomy_ceiling',
task_id: args.task_id,
agentLevel,
spaceLevel,
required: completionAutonomyLevel,
});
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.`,
});
}

Expand Down
Loading
Loading