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
38 changes: 30 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,8 @@ packages/utils/src/
~/.archcode/config.json → startup activation or token-protected Setup
→ optional Session auth → providers → registerBuiltinTools → live MCP runtime activation
→ Hono Runtime routes → Session-scoped Lead / Automation / HITL routes
→ SessionExecutionManager → ConfiguredAgent → query loop → store → SSE → Web UI
→ SessionExecutionManager → ConfiguredAgent authorized catalog
→ Core + State + Execution-loaded visibility → query loop → store → SSE → Web UI

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.
```
Expand Down Expand Up @@ -311,6 +312,24 @@ partitionToolCalls → global permissions

Every descriptor declares an explicit `outputPolicy`. Registry is the sole Raw-to-Finalized conversion boundary: blocked requests produce no settled result, while settled and synthetic results are finalized exactly once. `ToolOutputFinalizer` owns redaction of output/details and streaming capture redacts before artifact persistence; model, Session/SSE/UI, audit, and logger consume only finalized data. Large one-shot output is recovered through authorized, bounded `output_read` and `output_search` pages rather than a full-output escape hatch.

Model-visible tools are a projection, never an authorization source. Each
`AgentDefinition` declares `tools.authorized` and its strict `tools.core`
subset. `ConfiguredAgent` rebuilds the live authorized catalog at every model
boundary, then exposes Core, fixed runtime State activations, valid
Execution-local loaded refs, and `tool_search` only while deferred candidates
exist. Local long-tail and all ready MCP descriptors remain deferred until a
deterministic local search loads their contract for the next model step. The
Prompt lists every deferred canonical name with only the first description
line, capped at 160 characters, grouped by local namespace or MCP server. Models
prefer `select:<exact-name>`; only a query without that prefix uses the local
BM25/trigram ranking. Search never calls another model, never grants permission,
and never falls back from an exact miss to ranking or to an eager/load-all
surface. New Execution writes always include their tool-authorization snapshot
and loaded refs. Persisted records that predate those fields read with
`{ extraTools: [], toolProjection: null }` and `[]`; values that are present but
malformed remain invalid. No data-format version or migration framework is
introduced for this additive read boundary.

**Config** (`~/.archcode/config.json`): server-wide `provider.<id>.{npm, name, options, models}` + strict `profiles.{principal,deep,fast}.{model,variant,options}` + optional `memory`, `integrations.github`, and `mcp.{disabledBuiltins,servers}`. Each MCP server entry strictly requires `type: "http" | "stdio"` and `enabled`; HTTP uses `url`/`headers`, while STDIO uses `command`/`args`/`env`. Optional `connectTimeoutMs`, `discoveryTimeoutMs`, and `callTimeoutMs` default to 10,000/30,000/60,000 ms. Provider values are literal; MCP URL/header or STDIO env values and GitHub token resolution retain their environment-variable behavior. Project directories are never searched for configuration.

**Model configuration** (`~/.archcode/config.json`):
Expand Down Expand Up @@ -403,13 +422,13 @@ Minimal example:
All six implement `Agent`: `store: StoreApi<SessionStoreState>`, `run(options) → AgentResult`; SessionExecutionManager commits input before invoking the Agent. Visual is documentation-only future scope and has no runtime identity.

**Delegation + tool filtering:**
- Tool sets are hardcoded by `AgentDefinition`; typed RoleContract and Prompt layers describe behavior but never change runtime permissions.
- Role authorization and Core sets are hardcoded by `AgentDefinition`; typed RoleContract, Prompt, State activation, loaded refs, and search results never expand 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 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.
- `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. `extraTools` cannot restore that removed package. 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:**
Expand All @@ -422,11 +441,14 @@ All six implement `Agent`: `store: StoreApi<SessionStoreState>`, `run(options)
- Invalid package candidates are surfaced as `SKILL_INVALID_PACKAGE` diagnostics. A winning invalid package fails closed; resolution never falls through to a lower-precedence package. The same winning package is claimed once for one explicit `/skill use` logical Execution; an in-process resume reuses that snapshot, while process-restart recovery revalidates its persisted source/digest and fails closed on change.
- Skills remain guidance only: their package metadata and resources cannot grant tools or permissions, execute scripts automatically, change Agent/Profile/MCP/workspace scope/delegation, or grant completion authority. Scripts use only existing Bash permissions.

**MCP visibility**: User MCP servers are process-global and visible to all six
Agent identities from the current live runtime at the next model-call
boundary. They are not filtered by Agent role and do not add an approval step.
Built-in visibility remains the hardcoded role matrix in the MCP section below;
it is independent of user-server visibility.
**MCP visibility**: User MCP servers are process-global and authorized for all
six Agent identities from the current live runtime at the next model-call
boundary. Their full schemas are deferred behind `tool_search`; the bounded
per-tool Prompt projection contains each canonical name and only the first
description line, capped at 160 characters, grouped by server. It contains no
parameter schema and does not add an approval step. Built-in authorization
remains the hardcoded role matrix in the MCP section below and is independent
of user-server authorization.

**Query loop lifecycle:**
```
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,29 @@ and this project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

## [Unreleased]

### Changed

- Project each Agent's model-visible tools from a small role Core, current
runtime state, and Execution-local loaded contracts. Local long-tail and MCP
tools now start in a compact namespace/server directory: `tool_search` loads
a visible canonical name through exact `select:<name>`, while deterministic
keyword ranking remains an auxiliary path. MCP schemas no longer expand the
initial model request.
- New Execution writes include an immutable tool-authorization snapshot and
loaded tool-contract refs. Existing Session records that predate those fields
read with the canonical empty authorization overlay and no loaded refs;
explicitly malformed values remain invalid.

### Breaking Changes

- `tool_search` Tool Batch calls require the catalog digest captured at their
model boundary. There is no eager-tool or load-all fallback when search
fails.
- Tool-output artifact and tombstone metadata is hard-cut to version 2 because
artifact ownership now includes the producing Execution. Version 1 recovery
artifacts are rejected and removed during startup cleanup; they are not
migrated or exposed through a compatibility reader.

## [0.0.10] - 2026-08-21

### Added
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ describe("createRuntimeApp", () => {
origin: "user_message",
maxSteps: 50,
executionSkills: [],
toolAuthorizationSnapshot: { extraTools: [], toolProjection: null },
loadedToolRefs: [],
} });
listener({ type: "event", slug: "proj", sessionId: "session-1", eventId: 2, createdAt: 2, agentName: "lead", payload: {
type: "execution-end",
Expand Down
152 changes: 152 additions & 0 deletions apps/server/src/server-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,102 @@ function setupRequest(
};
}

async function seedLegalSession(
configService: ServerConfigService,
projectRegistry: ProjectRegistry,
runtimeStorageHomeDir: string,
workspaceRoot: string,
): Promise<string> {
const activation = await configService.activateForStartup();
if (activation.status !== "ready") throw new Error("Expected a valid test Config");
const runtime = await createRuntime({
configService,
activation: activation.activation,
projectRegistry,
runtimeStorageHomeDir,
});
let sessionId: string;
try {
const session = await runtime.createSession(workspaceRoot, {
agentName: "lead",
source: { kind: "direct" },
});
sessionId = session.sessionId;
} finally {
await runtime.shutdown();
}
return sessionId;
}

async function removeExecutionToolState(
workspaceRoot: string,
sessionId: string,
): Promise<void> {
const sessionPath = join(
workspaceRoot,
".archcode",
"runtime",
"sessions",
sessionId,
"session.json",
);
const persisted = JSON.parse(await readFile(sessionPath, "utf8")) as {
executions: Array<Record<string, unknown>>;
};
const executionId = "legacy-execution";
const startedAt = Date.now();
const binding = {
selection: { model: "local:test-model" },
providerId: "local",
modelId: "test-model",
providerDisplayName: "Local",
modelDisplayName: "Test model",
resolution: "profile_default",
modelRuntimeRevision: "legacy-fixture",
};
const zeroUsage = {
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
reasoningTokens: 0,
cachedInputTokens: 0,
};
persisted.executions = [{
id: executionId,
startedAt,
origin: "user_message",
maxSteps: 50,
durationMs: 0,
executionSkills: [],
memoryPolicy: {
policy: { useMemory: true, autoLearning: true },
epoch: { bootId: "legacy-fixture", generation: 0 },
},
runs: [{
ordinal: 0,
startedAt,
endedAt: startedAt,
durationMs: 0,
binding,
usageDelta: zeroUsage,
settlement: {
key: `run:${sessionId}:${executionId}:0`,
goalInstanceId: null,
},
}],
status: "completed",
endedAt: startedAt,
terminalSettlement: {
key: `terminal:${sessionId}:${executionId}`,
goalInstanceId: null,
},
}];
const execution = persisted.executions[0]!;
await writeFile(sessionPath, JSON.stringify(persisted));
expect(execution).not.toHaveProperty("toolAuthorizationSnapshot");
expect(execution).not.toHaveProperty("loadedToolRefs");
}

describe("ArchCodeServerHost", () => {
test("publishes the listener and control plane before a deferred Runtime settles", async () => {
const home = await createHome();
Expand Down Expand Up @@ -1599,6 +1695,62 @@ describe("ArchCodeServerHost", () => {
expect(attempts).toBe(2);
});

test("starts the production Runtime with a legacy Execution and serves the Session API", async () => {
const home = await createHome();
await mkdir(join(home, ".archcode"), { recursive: true });
await writeFile(
resolveServerConfigPath(home),
`${JSON.stringify(diskConfig())}\n`,
{ mode: 0o600 },
);
const workspaceRoot = join(home, "legacy-session-project");
await mkdir(workspaceRoot, { recursive: true });
const projectRegistry = new ProjectRegistry({ homeDir: home, logger: silentLogger });
const project = await projectRegistry.add({
workspaceRoot,
name: "Legacy Session Project",
});
const configService = new ServerConfigService({ homeDir: home });
const sessionId = await seedLegalSession(configService, projectRegistry, home, workspaceRoot);
await removeExecutionToolState(workspaceRoot, sessionId);
const runtimeDataService = new RuntimeDataService({ projectRegistry });
const host = await ArchCodeServerHost.create({
configService,
createRuntime: async (options) => await createRuntime({
...options,
runtimeStorageHomeDir: home,
}),
...hostInfrastructure(home),
projectRegistry,
runtimeDataService,
logger: silentLogger,
});

try {
host.startRuntimeActivation();
await waitForRuntimeState(host, "ready");
expect(host.getRuntimeStatus()).toEqual({ state: "ready" });

const inspection = await host.app.request("/api/runtime-data");
expect(inspection.status).toBe(200);
const inspectionBody = await inspection.json() as {
projects: Array<{ projectSlug: string; issues: unknown[] }>;
};
expect(inspectionBody.projects.find((candidate) => candidate.projectSlug === project.slug)?.issues)
.toEqual([]);

const sessions = await host.app.request(`/api/projects/${project.slug}/sessions`);
expect(sessions.status).toBe(200);
expect(await sessions.json()).toMatchObject({
sessions: expect.arrayContaining([
expect.objectContaining({ session: expect.objectContaining({ sessionId }) }),
]),
});
} finally {
await host.shutdown();
}
});

test("recovers in process with the real Runtime data service while preserving healthy project data", async () => {
const home = await createHome();
await mkdir(join(home, ".archcode"), { recursive: true });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type {
AssistantSessionPart,
ExecutionModelBindingSummary,
LoadedToolRef,
SessionExecutionRecord,
SessionMessage,
SessionStep,
ToolAuthorizationSnapshot,
} from "@archcode/protocol";
import { act, StrictMode } from "react";
import { createRoot, type Root } from "react-dom/client";
Expand Down Expand Up @@ -37,6 +39,11 @@ const memoryPolicy = {
policy: { useMemory: true, autoLearning: true },
epoch: { bootId: "test-memory-boot", generation: 0 },
};
const toolAuthorizationSnapshot: ToolAuthorizationSnapshot = {
extraTools: [],
toolProjection: null,
};
const loadedToolRefs: LoadedToolRef[] = [];
const usage = {
inputTokens: 0,
outputTokens: 0,
Expand All @@ -53,6 +60,8 @@ function completed(id = "execution"): SessionExecutionRecord {
origin: "user_message",
maxSteps: 10,
executionSkills: [],
toolAuthorizationSnapshot,
loadedToolRefs,
durationMs: 100,
status: "completed",
endedAt: 100,
Expand All @@ -79,6 +88,8 @@ function running(id = "execution"): SessionExecutionRecord {
origin: "user_message",
maxSteps: 10,
executionSkills: [],
toolAuthorizationSnapshot,
loadedToolRefs,
durationMs: 0,
status: "running",
runs: [{ ordinal: 0, startedAt: 0, binding }],
Expand Down Expand Up @@ -107,6 +118,8 @@ function suspended(
origin: "user_message",
maxSteps: 10,
executionSkills: [],
toolAuthorizationSnapshot,
loadedToolRefs,
durationMs: 10,
status: "suspended",
suspension,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import type { HitlView } from "@archcode/protocol";
import type { HitlView, LoadedToolRef, ToolAuthorizationSnapshot } from "@archcode/protocol";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { JSDOM } from "jsdom";
Expand Down Expand Up @@ -34,6 +34,12 @@ import { SettingsModalProvider } from "../../context/settings-modal";
import type { SessionGoalView } from "../../api/types";
import { queryKeys } from "../../api/queries";

const toolAuthorizationSnapshot: ToolAuthorizationSnapshot = {
extraTools: [],
toolProjection: null,
};
const loadedToolRefs: LoadedToolRef[] = [];

let dom: JSDOM;
let root: Root;
let container: HTMLDivElement;
Expand Down Expand Up @@ -425,6 +431,8 @@ describe("SessionComposerDock", () => {
status: "failed",
maxSteps: 10,
executionSkills: [],
toolAuthorizationSnapshot,
loadedToolRefs,
memoryPolicy: {
policy: { useMemory: true, autoLearning: true },
epoch: { bootId: "test-memory-boot", generation: 0 },
Expand Down
Loading