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
23 changes: 23 additions & 0 deletions .changeset/room-web-kit-acp-lanes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
---

Room-web drives its agents over `@rivus/agent-kit` 0.11.0 and schedules its turns with `@rivus/agent-kit-collab` 0.11.0. The private `@rivus/agent-orchestration` control-plane package — already application-only since the orchestration code moved into the endpoint — is deleted together with its tests. It was `"private": true`, so there is no npm action: no release, no deprecation flag, no dependents.

What replaced what, inside `apps/room-web`:

- `AcpConnector` is replaced by the kit's `/acp` entry (`connectAgent`, `probeAgent`, `builtinAcpProfiles`, `agentEnv`, `optionOfKind`) plus the endpoint's own mapping from an `agents` row to a profile (`infrastructure/agent-acp.server.ts`). Agents start with exactly the environment the profile picks — nothing is inherited wholesale as before — and the connection, not the endpoint, bounds the cancel at 5 s: a turn that ignores `session/cancel` gets its session binding removed and its connection closed.
- Sessions are bound per `room:<roomId>:member:<agentId>` key through `FileSessionBindingStoreLive` (next to the rooms library), so a session survives a server restart via `session/load` instead of being recreated with a fresh conversation.
- The handwritten wake-coalescing `AgentRuntime` is replaced by `createLanes` (`/lanes`; `maxConcurrent` 16, `maxQueued` 64, `turnTimeoutMs` 600 s). The lease, connection and prompt lifecycle live in the caller's `activate`; the host assembly root owns one `ManagedRuntime`, and wake/cancel run their Effects in callbacks with failures observed.
- The member lease runs on the kit's `/lease` (`createLeaseManager` over `sqliteLeaseRepository`, own `member-leases.sqlite` next to the rooms library), with the old timing: heartbeat 15 s, stale after 120 s. `member_leases` in the rooms database is dropped by migration 0008.
- `ToolServer` and the room-specific orchestration move into the endpoint (`application/tool-server.server.ts`, `application/turn.ts`), unchanged except that the unused stdio-shim transport is gone (room-web only ever used the HTTP transport).
- The room's permission policy is answered by the kit's connection: `connectRoomAgent` wraps the turn's `cwdPermissionPolicy` into the kit's `onPermission` callback (read per activation, since the connection outlives a turn), so a judged-inside request gets the allow option it used to be silently denied. An activation that dies in the connect or session-open window writes its one failed row through afterTurn with that error, the way the replaced runtime did, instead of a timeout row plus a second failed row.

Overlap with open branches, so the merge is not a surprise:

- [PR #144](https://github.com/PerfectPan/agent-task-loop/pull/144) (`feat/room-public-origin`, open) edits the same two files this branch does: `docs/architecture/agent-collaboration.md` (its Room-mutations hunk covers old lines 149–161; this branch's nearest hunk starts at old line 160) and `apps/room-web/app/room-lab/infrastructure/local-guard.server.ts` (this branch's lint pass reformatted it). A merge dry run from this branch (`git merge-tree --write-tree HEAD feat/room-public-origin`, merge base 96ff874) reports content conflicts in both files. Neither side changes what the other wrote: #144 rewrites the origin-guard prose and adds `ROOM_PUBLIC_ORIGIN` handling to the guard itself; this branch rewrites the runtime and permission prose and only reformatted the guard. Resolve by keeping both texts. This branch has no pull request of its own yet; whoever opens it repeats this list in the PR description and re-runs the merge check after #144 lands or after rebasing onto it.

Accepted corrections from the old/new comparison (behavior differences, deliberately kept):

- The permission policy reads the agent's `rawInput` paths instead of the adapter-reported `locations` the old `ToolCallUpdate` carried (the kit's `PermissionRequest` has no `locations`). Relative paths are no longer treated as outside-`cwd` locations; they are simply not judged, and a write-kind call naming only relative paths is denied for naming no judged location.
- The system prompt now travels once per session (in `_meta.systemPrompt` for the claude profile, as the first prompt block otherwise) instead of being re-sent on every turn of a reused session.
- The turn timeout and lease heartbeat move from a per-turn watchdog plus a heartbeat timer in the runtime to the kit's lane timeout and the lease manager's own renewal fiber; the observable bounds are the same (10-minute default turn, 15 s/120 s lease timing).
65 changes: 0 additions & 65 deletions .github/workflows/moonbit-publish.yml

This file was deleted.

2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ Tools and libraries use MIT; applications use GPL-3.0-only. The repository is li
| ------------------------- | ---------------------------- |
| `@rivus/agent-finder-cli` | `packages/agent-finder-cli/` |

Everything else, including `@rivus/agent-task-loop`, `@rivus/agent-room`, `@rivus/agent-orchestration` and `apps/`, is GPL-3.0-only. Distributing a GPL-3.0-only package or a modified version requires releasing its source under the same license.
Everything else, including `@rivus/agent-task-loop`, `@rivus/agent-room` and `apps/`, is GPL-3.0-only. Distributing a GPL-3.0-only package or a modified version requires releasing its source under the same license.

Package metadata (`package.json`) uses the SPDX identifier of the package's license, and an MIT package lists `LICENSE` in its published files. An MIT package must not import or bundle code from a GPL-3.0-only workspace package, so both stay under one license when shipped together. GPL-3.0-only packages may use the MIT ones. `pnpm test` enforces the rule from the metadata: [`tests/license/license-boundary.test.mjs`](tests/license/license-boundary.test.mjs) fails when a workspace package has no `license` field or an MIT package lists a GPL-3.0-only workspace package in any dependency field. Change a license only as a deliberate project decision, and keep third-party notices for code or data copied from other projects.

Expand Down
140 changes: 140 additions & 0 deletions apps/room-web/app/room-lab/application/permission-policy.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { lstatSync, readlinkSync, realpathSync } from 'node:fs';
import path from 'node:path';
import type { PermissionDecision, PermissionRequest } from '@rivus/agent-kit/acp';
import type { TurnPermissionPolicy } from './ports';

function isInside(root: string, target: string): boolean {
const within = path.relative(root, target);
// `..` and `..`-prefixed components only: a name that merely starts with
// two dots (`..foo`) is an ordinary in-root name.
return within === '' || (within !== '..' && !within.startsWith(`..${path.sep}`) && !path.isAbsolute(within));
}

function realPathOf(target: string): string | undefined {
try {
return realpathSync(target);
} catch {
return undefined;
}
}

/**
* Where a call's path really points, symlinks included: every component is
* walked and each link followed, so a link that lives inside the root but
* points out is judged by where it points. Past the first component that
* does not exist nothing below can exist either — there is no link left to
* hide in — so the rest hangs off the last real directory as written.
* Returns undefined when the walk gives up: an unreadable link, or a chain
* long enough to be a loop — the caller denies, because handing such a path
* to the OS is exactly how it would follow the rest of the way out.
*/
function resolvedTarget(target: string): string | undefined {
let current = path.resolve(target);
for (let hops = 0; hops < 40; hops += 1) {
const parts = current.split(path.sep);
let walked: string = path.sep;
let followed = false;
for (let index = 1; index < parts.length; index += 1) {
const part = parts[index]!;
if (!part) {
continue;
}
const next = path.join(walked, part);
let stat: { isSymbolicLink(): boolean };
try {
stat = lstatSync(next);
} catch {
return path.join(walked, ...parts.slice(index).filter(Boolean));
}
if (!stat.isSymbolicLink()) {
walked = next;
continue;
}
let linkTarget: string;
try {
linkTarget = readlinkSync(next);
} catch {
return undefined;
}
// The link's own target may hold links of its own: walk it next pass,
// with the rest of the original path hanging off it.
const rest = parts.slice(index + 1).filter(Boolean);
current = path.join(path.isAbsolute(linkTarget) ? linkTarget : path.resolve(walked, linkTarget), ...rest);
followed = true;
break;
}
if (!followed) {
return walked;
}
}
return undefined;
}

/**
* The absolute paths an agent's raw tool input names, however it names them:
* every string value in the input that starts with `/`, collected depth
* first with a depth and size bound so a body-sized blob cannot stall the
* answer. The kit's permission request carries the agent's own `rawInput`
* rather than adapter-reported locations, so the candidate paths are the
* input's — a relative path is indistinguishable from any other string and
* is simply not judged (and never invented as one).
*/
function rawInputPaths(raw: unknown): string[] {
const paths: string[] = [];
const visit = (value: unknown, depth: number): void => {
if (paths.length >= 64 || depth > 8) {
return;
}
if (typeof value === 'string') {
if (value.length > 0 && value.length <= 4096 && value.startsWith('/')) {
paths.push(value);
}
return;
}
if (Array.isArray(value)) {
for (const item of value) {
visit(item, depth + 1);
}
return;
}
if (value !== null && typeof value === 'object') {
for (const item of Object.values(value)) {
visit(item, depth + 1);
}
}
};
visit(raw, 0);
return paths;
}

/**
* The default policy for a Room turn: writes inside `cwd` are allowed, writes
* outside it are denied. Paths are resolved before the comparison — a symlink
* pointing out of `cwd` is out of `cwd` — and a write-kind call whose input
* names no path is denied rather than assumed harmless. Everything the policy
* cannot judge, it denies: returning undefined is the kit's denial.
*/
export function cwdPermissionPolicy(cwd: string): TurnPermissionPolicy {
const root = realPathOf(cwd) ?? cwd;
return (request: PermissionRequest): PermissionDecision | undefined => {
const call = request.toolCall;
const paths = rawInputPaths(call.rawInput);
const writeKind = call.kind === 'edit' || call.kind === 'delete' || call.kind === 'move';
// A chain the walker gives up on is exactly where the OS would follow it
// out of the root; it is denied.
const outside = paths.some((candidate) => {
const resolved = resolvedTarget(candidate);
return resolved === undefined || !isInside(root, resolved);
});
const wanted =
outside || (writeKind && paths.length === 0) ? ['reject_once', 'reject_always'] : ['allow_once', 'allow_always'];
for (const kind of wanted) {
const option = request.options.find((candidate) => candidate.kind === kind);
if (option) {
return { optionId: option.optionId };
}
}
// No reject option to select: the kit denies the request for us.
return undefined;
};
}
57 changes: 47 additions & 10 deletions apps/room-web/app/room-lab/application/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import type {
RoomStreamStore,
SliceBudget,
} from '@rivus/agent-room';
import type { AgentRegistry, LeaseRecord, ToolDefinition } from '@rivus/agent-orchestration';
import type { HostedTools } from '@rivus/agent-orchestration/acp';
import type { LeaseSnapshot } from '@rivus/agent-kit-collab/lease';
import type { RoomLabAgentId } from '../domain/agent-registry';
import type { RoomTurnView } from '../read-model';
import type { HostedTools } from './tool-server.server';
import type { ToolDefinition, TurnHarness, TurnPermissionPolicy, TurnResult } from './turn';

/** How a turn may carry the record: 50 events, up to 48k characters. */
export const TURN_BUDGET: SliceBudget = { maxEvents: 50, maxChars: 48_000 };
Expand All @@ -18,14 +19,44 @@ export const TURN_BUDGET: SliceBudget = { maxEvents: 50, maxChars: 48_000 };
export const HELD_LIMIT = 3;

/**
* The scheduler half of the control plane, as the dispatcher sees it: wakes
* coalesce into one pending flag per key and never block the caller (docs/architecture/agent-collaboration.md).
* One key per (room, member), shared by the scheduler and the lease:
* `room:<roomId>:member:<agentId>`.
*/
export function runtimeKey(roomId: string, agentId: RoomLabAgentId): string {
return `room:${roomId}:member:${agentId}`;
}

/** One member's row, as the control plane reads it from the `agents` table. */
export interface Agent {
id: RoomLabAgentId;
label: string;
/** The ACP command line the agent's profile starts. */
command: string;
systemPrompt: string;
/** The turn watchdog's own bound for this member; the design default otherwise. */
timeoutMs?: number;
}

/** The control plane's roster port: the `agents` table read and written through it. */
export interface AgentRegistry {
list(): Promise<Agent[]>;
get(id: string): Promise<Agent | undefined>;
save(agent: Agent): Promise<void>;
remove(id: string): Promise<void>;
}

/**
* The scheduler half, as the dispatcher sees it. Implemented over
* `createLanes` (`@rivus/agent-kit-collab` `/lanes`): a wake asks for one
* activation of the key, and while one runs or waits, further wakes coalesce
* into at most one more activation. It never blocks the caller; a failure to
* ask is observed by the host, not thrown here.
*/
export interface RoomMemberRuntime {
wake(key: string): void;
/**
* Stops a running activation (a reset cancels the turns it invalidates);
* a scheduler without a cancel simply lets them run out.
* Stops a running activation and drops the wakes it owes (a reset cancels
* the turns it invalidates); a scheduler without a cancel lets them run out.
*/
cancel?(key: string): void | Promise<void>;
}
Expand Down Expand Up @@ -110,9 +141,9 @@ export type RoomSettingsReader = () => RoomSettings;
/**
* The port surface one RoomService is built on. The record and the write
* points are `RoomStreamStore` (admit, speak, pass, readSlice); the registry is
* the control plane's roster port.
* the control plane's roster port; the turn contract lives in `./turn`.
*/
export type { RoomStreamStore, AgentRegistry };
export type { RoomStreamStore, ToolDefinition, TurnHarness, TurnPermissionPolicy, TurnResult };

/**
* The record plus the session side-channels the turn reads it with: the
Expand All @@ -134,10 +165,16 @@ export interface RoomRecordStore extends RoomStreamStore {
}): Promise<RoomEvent>;
}

/** The lease half the dispatcher's writes run under. */
/** The lease half the dispatcher's writes run under (the kit's `/lease`). */
export interface RoomLeases {
/**
* Linearizes one external write against every holder of this key: the write
* runs only while the caller still holds the member lease, and a successor's
* write starts only after it settled.
*/
fence<T>(key: string, op: () => Promise<T>): Promise<T>;
read(key: string): LeaseRecord | undefined;
/** The key's lease record, for the member status display. */
read(key: string): Promise<LeaseSnapshot | undefined>;
}

/**
Expand Down
Loading
Loading