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
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ packages/utils/src/
→ Hono Runtime routes → Session-scoped Lead / Automation / HITL routes
→ SessionExecutionManager → ConfiguredAgent → query loop → store → SSE → Web UI

Delegation: `delegate(DelegationRequest)` creates a durable direct child; `resume_session` preserves its Agent, Profile, Skills, and responsibility. Every child finishes with a normal assistant response; synchronous delegation returns that final response directly, while background work is read through `background_output`. If a synchronous child suspends, its parent suspends on the original tool call; each resumes its own same logical Execution when ready. `SessionExecutionManager` is the sole owner of Execution lifecycle, admission, concurrency, live run resources, recovery, and terminal records. There is no Build owned-scope or lease subsystem.
Delegation control is a fixed seven-tool package: `delegate`, `list_agents`, `send_message`, `background_output`, `wait_for_reminder`, `cancel_session`, and `resume_session`. `delegate(DelegationRequest)` creates a durable direct child; `list_agents` reads the caller's descendant subtree through the same backend Agent Tree projection used by the Web tree; and `send_message` is the only parent-to-child message path, with `delivery: "steer" | "queue"` selecting the current Execution's next model boundary or the next Execution. `background_output` reads a direct child's result, `wait_for_reminder` waits on direct children, `cancel_session` strongly cascades to any descendant subtree, and `resume_session` continues a stopped direct child while preserving its Agent, Profile, Skills, and responsibility. Every child finishes with a normal assistant response; synchronous delegation returns that final response directly, while background work is read through `background_output`. If a synchronous child suspends, its parent suspends on the original tool call; each resumes its own same logical Execution when ready. `SessionExecutionManager` is the sole owner of Execution lifecycle, admission, concurrency, live run resources, recovery, and terminal records. There is no Build owned-scope or lease subsystem.
```

**Server + Web UI:**
Expand Down Expand Up @@ -405,11 +405,12 @@ All six implement `Agent`: `store: StoreApi<SessionStoreState>`, `run(options)
**Delegation + tool filtering:**
- Tool sets are hardcoded by `AgentDefinition`; typed RoleContract and Prompt layers describe behavior but never change runtime permissions.
- Profiles route model resources only; Skills provide guidance only. Neither changes tools, delegation targets, or completion authority.
- `DELEGATION_CONTROL_TOOLS` is the fixed seven-tool package: `delegate`, `list_agents`, `send_message`, `background_output`, `wait_for_reminder`, `cancel_session`, and `resume_session`. Lead, Discussion, Analyst, and Build explicitly spread this package in their own `AgentDefinition`; Explore and Librarian do not configure it.
- `lead` uses `childPolicy.maxDepth = 3`; `discussion`, `analyst`, and `build` use `maxDepth = 2`. Discussion may delegate Explore/Librarian.
- Lead targets Analyst/Build/Explore/Librarian; Analyst targets Explore/Librarian; Build targets Explore.
- `explore` and `librarian` have no `delegateTargets`; they are terminal read-only support agents.
- `agents/factory.ts` owns one immutable current-Agent/depth delegation capability snapshot and removes delegation tools at each definition's `childPolicy.maxDepth` or when no direct target exists. Prompt/Tool projection and SessionExecutionManager admission consume that same target/Profile/builtin-Skill authority; Provider-facing Tool schemas remain portable presentation contracts while strict internal schemas still validate execution input.
- `delegate` persists Agent, Profile, Skills, title, objective, and background choice. `resume_session` preserves that identity. Multiple Builds share general Session concurrency; there is no owned-scope or Build lease subsystem.
- `agents/factory.ts` owns one immutable current-Agent/depth delegation capability snapshot and only removes the explicitly configured delegation package at each definition's `childPolicy.maxDepth` or when no direct target exists; it never injects delegation tools. Prompt/Tool projection and SessionExecutionManager admission consume that same target/Profile/builtin-Skill authority; Provider-facing Tool schemas remain portable presentation contracts while strict internal schemas still validate execution input.
- `list_agents` and the Web Agent Tree use one backend projection of durable family topology plus live Execution/Link facts. `send_message` targets only a running direct child and uses `steer | queue`; `cancel_session` accepts any descendant and strongly cascades its subtree, while `wait_for_reminder` and `resume_session` remain direct-child operations. `delegate` persists Agent, Profile, Skills, title, objective, and background choice; `resume_session` preserves that identity. Multiple Builds share general Session concurrency; there is no owned-scope or Build lease subsystem.

**Workflow Skills:**
- Ordinary root Lead activates `orchestrate-work`; active Goal activates `run-goal`; root Discussion activates `shape-todo`, derived from authoritative runtime facts on every Execution.
Expand Down Expand Up @@ -452,7 +453,7 @@ Successful root Lead/Discussion terminals update the durable Memory cursor;
| Interaction | ask_user✅❌not-concurrent, todo_write❌, project_todo_update❌ | ask_user serializes (interactive); `project_todo_update` derives its Todo from the current bound root Discussion and requires `expectedRevision` |
| Web | web_fetch✅ | — |
| LSP | lsp_diagnostics✅, lsp_goto_definition✅, lsp_find_references✅, lsp_symbols✅ | Guard: workspace |
| Delegation / Skills | delegate❌, resume_session❌, background_output✅, wait_for_reminder✅, cancel_session❌, skill_list✅, skill_read✅ | `delegate` accepts only strict `{ agent_type, profile, title, objective, skills, background }`; `resume_session` accepts only `{ session_id, instruction, background }`; delegated roles return ordinary final assistant text. Only Lead has family cancel. |
| Delegation / Skills | delegate❌, list_agents✅, send_message❌, background_output✅, wait_for_reminder✅, cancel_session❌, resume_session❌, skill_list✅, skill_read✅ | The seven control tools are explicitly configured by Lead, Discussion, Analyst, and Build; Explore and Librarian do not receive them. `delegate` accepts only strict `{ agent_type, profile, title, objective, skills, background }`; `list_agents` returns only the bounded caller-descendant Agent Tree projection; `send_message` accepts `{ session_id, expected_execution_id, message, delivery: "steer" | "queue" }` for a running direct child; `background_output` reads direct-child results; `wait_for_reminder` waits on direct children; `cancel_session` strongly cascades any descendant subtree; and `resume_session` accepts only a stopped direct child with `{ session_id, instruction, background }`. Delegated roles return ordinary final assistant text. |
| Tool output recovery | output_read✅, output_search✅ | All agents may retrieve only authorized, bounded artifact pages or search results. |
| Memory | memory_read✅, memory_write❌ | memory_write rejects secrets |
| Goal / Automation creation | create_goal❌, get_goal✅, update_goal❌, automation_create❌ | Before a root Lead calls strict `create_goal({ objective })`, it uses ordinary `ask_user` and interprets the answer semantically. Goal creation never parses an initial budget from objective text; users control budget through the Session API/UI. Before completion, Lead uses a fresh direct deep Analyst with `goal-review`, interprets its ordinary report, and calls strict `update_goal({ status, reason })`; Runtime retains only active-family and instance/generation consistency checks. |
Expand Down
19 changes: 16 additions & 3 deletions apps/server/src/routes/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const snapshot = {
},
},
profiles: {},
permissions: { autoReview: true },
},
revision: "revision-1",
modelRuntimeRevision: "revision-1",
Expand Down Expand Up @@ -96,15 +97,27 @@ describe("config routes", () => {
});

test("returns the independent MCP apply result with a config save", async () => {
const service = createService();
const disabledConfig = {
...snapshot.config,
permissions: { autoReview: false },
};
const disabledResponse = {
...savedResponse,
config: disabledConfig,
};
const service = createService({ save: mock(async () => disabledResponse) });
const response = await createApp(service).request("/", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ expectedRevision: "revision-1", config: snapshot.config }),
body: JSON.stringify({ expectedRevision: "revision-1", config: disabledConfig }),
});

expect(response.status).toBe(200);
expect(await response.json()).toEqual(savedResponse);
expect(await response.json()).toEqual(disabledResponse);
expect(service.save).toHaveBeenCalledWith({
expectedRevision: "revision-1",
config: disabledConfig,
});
});

test("returns the secret-free model runtime catalog", async () => {
Expand Down
40 changes: 37 additions & 3 deletions apps/server/src/routes/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, rm } from "node:fs/promises";
import { resolve } from "node:path";
import type { AgentRuntime } from "@archcode/agent-core";
import { NotRootSessionError, ProjectRegistry, SessionAutomationReferenceConflictError, SessionDeleteConflictError, SessionDeleteInProgressError, SessionFamilyStopConflictError, SessionFamilyStopInProgressError, SessionModelSelectionNotAllowedError, silentLogger } from "@archcode/agent-core";
import { AgentTreeProjectionError, NotRootSessionError, ProjectRegistry, SessionAutomationReferenceConflictError, SessionDeleteConflictError, SessionDeleteInProgressError, SessionFamilyStopConflictError, SessionFamilyStopInProgressError, SessionModelSelectionNotAllowedError, silentLogger } from "@archcode/agent-core";
import { createRuntimeApp } from "../app";

const tempRoot = resolve(import.meta.dir, "__test_tmp__", "sessions-routes");
Expand Down Expand Up @@ -183,6 +183,13 @@ function createTestRuntime(projectRegistry: ProjectRegistry) {
return sessions.get(`${input.workspaceRoot}\0${input.sessionId}`)!;
},
listSessionTree: async (workspaceRoot: string, rootSessionId: string) => {
if (rootSessionId === "tree-conflict") {
throw new AgentTreeProjectionError(
"active_execution_mismatch",
rootSessionId,
"Agent Tree snapshot changed during capture",
);
}
const key = `${workspaceRoot}\0${rootSessionId}`;
const session = sessions.get(key);
if (!session) throw new MissingSessionFileError();
Expand All @@ -191,19 +198,27 @@ function createTestRuntime(projectRegistry: ProjectRegistry) {
}
type RuntimeTreeNode = {
session: { sessionId: string; rootSessionId: string; parentSessionId?: string; title: string | null; createdAt: number };
depth: number;
latestExecutionStatus: string | null;
activeExecutionId: string | null;
linkStatus: string | null;
children: RuntimeTreeNode[];
};
const toNode = (nodeSession: StoredSessionBody): RuntimeTreeNode => ({
const toNode = (nodeSession: StoredSessionBody, depth = 0): RuntimeTreeNode => ({
session: {
sessionId: nodeSession.sessionId,
rootSessionId: nodeSession.rootSessionId,
...(nodeSession.parentSessionId === undefined ? {} : { parentSessionId: nodeSession.parentSessionId }),
title: nodeSession.title ?? null,
createdAt: nodeSession.createdAt,
},
depth,
latestExecutionStatus: nodeSession.executions.at(-1)?.status ?? null,
activeExecutionId: null,
linkStatus: null,
children: [...sessions.entries()]
.filter(([entryKey, candidate]) => entryKey.startsWith(`${workspaceRoot}\0`) && candidate.parentSessionId === nodeSession.sessionId)
.map(([, candidate]) => toNode(candidate)),
.map(([, candidate]) => toNode(candidate, depth + 1)),
});

return {
Expand Down Expand Up @@ -723,12 +738,31 @@ describe("sessions routes", () => {
title: "Root",
createdAt: 1000,
},
depth: 0,
latestExecutionStatus: null,
activeExecutionId: null,
linkStatus: null,
children: [],
},
diagnostics: [],
});
});

test("GET /api/projects/:slug/sessions/:sessionId/tree returns 409 for an unstable projection", async () => {
const { app, project } = await createTestApp("tree-conflict");

const res = await app.request(`/api/projects/${project.slug}/sessions/tree-conflict/tree`);

expect(res.status).toBe(409);
expect(await res.json()).toEqual({
error: {
code: "BAD_REQUEST",
message: "Agent Tree snapshot changed during capture",
details: { scopeCode: "AGENT_TREE_SNAPSHOT_CONFLICT" },
},
});
});

test("GET /api/projects/:slug/sessions/:sessionId/tree returns 400 for child session", async () => {
const { app, project, workspaceRoot, sessions } = await createTestApp("tree-child");
const childSession = createStoredSession({ sessionId: "child-session", rootSessionId: "root-session", parentSessionId: "root-session", createdAt: 1000, title: "Child" });
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/routes/sessions.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Hono } from "hono";
import {
AgentTreeProjectionError,
NotRootSessionError,
SessionDeleteConflictError,
SessionDeleteInProgressError,
SessionAutomationReferenceConflictError,
SessionFamilyStopConflictError,
SessionFamilyStopInProgressError,
SessionFamilySnapshotConflictError,
SessionFileNotFoundError,
SessionGoalServiceError,
SessionModelSelectionConflictError,
Expand Down Expand Up @@ -149,6 +151,11 @@ export function createSessionsRoutes(runtime: AgentRuntime): Hono {
if (error instanceof SessionFileNotFoundError || isMissingFileError(error)) {
throw new SessionNotFoundError(sessionId);
}
if (error instanceof AgentTreeProjectionError || error instanceof SessionFamilySnapshotConflictError) {
throw new ServerError("BAD_REQUEST", error.message, 409, {
scopeCode: "AGENT_TREE_SNAPSHOT_CONFLICT",
});
}
throw error;
}
});
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import type {
ProjectAutomationInventoryItem,
ProjectTodoPlan,
ProjectTodoAttachmentListResponse,
SessionTreeResponse,
AgentTreeProjection,
ProjectTodo,
} from "./types";
import {
Expand Down Expand Up @@ -172,7 +172,7 @@ export function sessionTreeQueryOptions(slug: string, rootSessionId: string) {
return queryOptions({
queryKey: queryKeys.tree(slug, rootSessionId),
queryFn: async () => {
const response = await apiFetch<SessionTreeResponse>(
const response = await apiFetch<AgentTreeProjection>(
`/api/projects/${encodeURIComponent(slug)}/sessions/${encodeURIComponent(rootSessionId)}/tree`,
);
return response;
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/api/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export type {
AgentDescriptor,
AgentTreeNode,
AgentTreeProjection,
Project,
DirectoryEntry,
DirectoryListResponse,
Expand Down
Loading