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
1 change: 1 addition & 0 deletions apps/harbor/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ How the server is built — modules, invariants, the slice a capability cuts thr
- **Jev, the built-in agent** (`BUILT_IN_CONNECTION`, `JEV_KIND`, migration 033 — 2026-10-07, [spec §8](./SPEC.md#8-agents-in-spaces) Jev). With `HARBOR_JEV_OPENROUTER_KEY` set, every org has exactly one member with `kind: agent`, `agentKind: jev`, `agentConnection: builtin`, `displayName: Ro` (people meet it as Ro; Jev is the model it runs on), and no `ownerId`. It appears in `listOrgMembers` and can be added with `addMembers` like any agent. It is not in `AGENT_PAIRS`, so `createAgent` refuses it (`invalid_request`), and as an ownerless agent it takes no keys and no credential. In a shared space it is a member of, after each message it did not write, it may post a reply in that message's thread whose body is only mention tokens, `cc [@Name](#member:id) …` (at most three). That reply invokes the agents it mentions like any message does, at the hand-off depth of the message it answers, not a fresh depth for Jev; migration 033 (`message_hops`) records that depth when an agent posts. A mention of Jev in a space settles `done`; a message in a DM with it settles `failed`, with an `error` saying it does not chat. Older clients draw it as a generic agent.
- **Agent invocations** (`Invocation`, `InvocationUpdate`, `ConnectorCapabilities` in `invocation.ts`; the `invocation`, `invocation_stop` and `invocation_state` frames; migration 027 — 2026-09-30, [spec §8](./SPEC.md#8-agents-in-spaces) Invoking agent members). A posted message that mentions an agent member (not its author) invokes it, and so does every message a person posts in a direct space with an agent member, mention or not (2026-09-30), decided in the message's own transaction. `postMessage` answers with `invocations` for every agent it invoked, or was refused to: `refused` with `not_permitted` when the invoker shares no shared space with the agent (a DM alone does not count), or `hop_limit` when an agent's post would be the fourth hand-off (depth = 1 + the deepest live invocation of the posting agent; a person's mention is 0). The `post_message` tool returns the same as `invocations: [{agentId, state, refusal?}]`. `NewMessage.agentOptions` (keyed by agent id) lands on that agent's invocation uninterpreted; entries for agents the message does not invoke are dropped. Edits never invoke. Per (agent, conversation), where the conversation is the thread root, one invocation is live at a time: a new one is `pending` when the conversation is free, `pending` with `answers` when the live one is `waiting`, and otherwise `queued`, promoted to `pending` when the live one ends, in the order the triggering messages were posted (their stream offsets, assigned under the space lock, so the order holds across Harbor instances whatever their clocks say). Every state change runs in a transaction holding the space's Postgres advisory lock, so the queue is correct with any number of Harbor instances on one database. A `working` invocation that has reported nothing for 30 minutes is marked `failed` the next time its conversation needs the turn; `pending` and `waiting` never time out. **The connector's routes take an agent key and act only on that agent's own invocations** (a person is `forbidden`, another agent's invocation `not_found`): `GET /v1/agent/invocations` lists the pending, working and waiting ones for a reconnecting connector; `POST /v1/agent/invocations/:invocationId/ack` moves pending to working (idempotent; a cancelled one answers `invalid_request`, drop it); `POST /v1/agent/invocations/:invocationId/update` takes an `InvocationUpdate` (`working` as a heartbeat with an optional `activity` and `link`, `waiting` with the `activity` it needs, `done`, `failed` with an `error`, `cancelled`), refused once the invocation is finished; `POST /v1/agent/capabilities` declares `ConnectorCapabilities` (`stop`, up to 8 `options`), again whenever they change. **Option defaults** (2026-10-01, migration 030): `PUT /v1/agents/:agentId/option-defaults` with `{defaults}` sets the defaults for the options the agent's connector declared, its owner only, app only; each key a declared option and each value a declared choice's id or a toggle's boolean (`invalid_request` otherwise); it replaces them all, and `{}` clears them. `GET /v1/agents/:agentId/capabilities` returns them as `defaults` beside `capabilities`. A new invocation's `options` are the defaults that still fit the declared options, overlaid by the invoker's `agentOptions` for that agent, whoever invoked it. An agent's connections get `invocation` when one becomes pending (new, or out of the queue) and `invocation_stop` when someone stops a running one; both are ephemeral. A connector lists its invocations when it connects and again every minute: the frame is the fast path, the list the guarantee, including while live delivery is per instance (the hub is in-process until it gets a shared bus). **People's side:** `GET /v1/agents/:agentId/capabilities` (any org member: the composer's options, whether Stop is offered); `GET /v1/spaces/:spaceId/invocations?threadRootId=` (readable space, newest first, at most 100; the `get_invocations` tool); `POST /v1/invocations/:invocationId/cancel` (the `stop_invocation` tool): a queued or pending one is cancelled at once by its invoker only, and a working or waiting one is stopped by its invoker or an admin only when the connector declared `stop` (`stopRequested` is set, the agent gets `invocation_stop`, and the connector reports `cancelled`); a finished one comes back unchanged. Every change of state reaches the space's subscribers as an ephemeral `invocation_state` frame. Older clients ignore the new frames and fields.
- **Agent approvals** (`Approval`, `ApprovalRequest`, `ApprovalDecision`, `ApprovalClose`, `APPROVAL_CHOICE_LABELS` in `approval.ts`; `Message.approval`; the `approval` space event and the `approval_decided` frame; migration 029 — 2026-10-01, [spec §8](./SPEC.md#8-agents-in-spaces) part 4). An approval is its own record on an invocation, riding on the agent's card message as a poll rides on its message: the message row keeps the approval as raised, reads fold in the current one, and every change after raising is an `approval` event carrying the whole approval (folding clients replace the card's `approval`). Choices are `allow_once`, `allow_session` (shown as "Allow in this thread"), `allow_always` and `deny`; states are `open`, then `allowed`, `denied`, `expired` or `cancelled`. **The connector's routes take an agent key, about that agent's own approvals:** `POST /v1/agent/invocations/:invocationId/approvals` raises one on a working or waiting invocation with `{requestKey, title, detail, reason?, choices}` and answers `{approval, message}`: it posts the agent's card as a reply in the invocation's thread (its body a text rendering: the title, the detail in a code block, the reason, the choices) in the same transaction, and the invocation becomes `waiting` with the activity `Waiting for approval: <title>`; raising the same `requestKey` again returns the first. While any of its approvals is open the invocation stays `waiting` through a `working` heartbeat, and a mention of its agent in the thread is `queued`, not taken as an answer. `GET /v1/agent/invocations` also returns `approvals`: the decided ones its connector has not yet confirmed; `POST /v1/agent/approvals/:approvalId/applied` confirms one (idempotent); `POST /v1/agent/approvals/:approvalId/close` with `{state: 'expired' | 'cancelled'}` settles an open one its agent gave up on (a settled one comes back unchanged). When the last open approval is settled, a `waiting` invocation goes back to `working`; an invocation that ends cancels its open approvals. **People's side:** `POST /v1/spaces/:spaceId/approvals/:approvalId/decide` with `{decision, note?, actingMode}`: any member of the space, acting directly (`actingMode: 'agent'` or an agent key is `forbidden`), REST only, no tool; one of the offered choices; a `note` only with a `deny`; the first decision wins (`invalid_request` after). The agent's connections get `approval_decided` (ephemeral; the listing is the guarantee). Older clients ignore the new field, event and frame, and show the card's body.
- **A binary file's read says where its bytes are** (`read_asset` — 2026-10-08, [spec §8](./SPEC.md#8-agents-in-spaces) Files in a message). No tool carries a file's bytes. On the agent face, `read_asset` of a binary version adds a second text block to the tool result: `GET <origin>/v1/spaces/<spaceId>/blobs/<hash>` with the caller's bearer, `<origin>` being the address the caller reached `/mcp` on. The structured result is `ReadAssetResult` unchanged, so both faces return the same object, and a text file's read has no second block. Additive.
- **The roster is the whole org** (`listOrgMembers`, `GET /v1/members`, `list_members` without a space — 2026-09-29, [spec §5](./SPEC.md#5-the-space) answer 4). It returns every member of the org, people and agents, sorted by display name case-insensitively with the id breaking ties, and it's the same answer for every caller: inside one org, the org is the trust boundary. Until this date it was bounded to members sharing a space with the caller. Nothing else widens: DMs to any org member were already allowed, mentions of a non-member are still dropped when the message is saved, and a space's own roster (`listMembers`) is unchanged. The wire shape is unchanged, so older clients simply see more people. No pagination in v1.
- **A departure ends both the act and the stream** (`Kernel.lockedAs`, the `space_removed` member frame — 2026-09-22). A member's write re-verifies access **inside the space lock** — the same `canAccessSpace` decision the gate ran, against the membership as it stands in the transaction — so a write that passed the gate and then waited behind a removal is refused (`forbidden`) instead of landing after the `left` event; nothing is written. When a membership ends, the org tells every connection the member holds with `space_removed {spaceId, by, at}` (`by` is the member on leave, the remover once removal exists); the live face drops that space's subscription **before** forwarding the frame, so no frame of the space follows the departure, and a re-subscribe is refused for private spaces. Amended 2026-09-23: a departed member can explicitly re-subscribe to an open space as a browser; writing still requires rejoining. Pending replay is canceled when its subscription is removed or replaced. Ephemeral, never replayed — the durable truth is the `membership` event (`left` / `removed`) on the space's own log. Pre-2026-09-22 clients drop the unknown frame by contract.
- **Any member may rename a shared space** (`renameSpace`, the `space_renamed` event — 2026-09-09): Slack channel semantics, no owner. Direct spaces refuse — a DM is named by the other person. An identical name is an idempotent 200 no-op with no event; a real rename appends `space_renamed` with the full row and the actor, so every listing updates.
Expand Down
Loading
Loading