From b32f5b031153b79aa8d2a32568ca6abc46b33d96 Mon Sep 17 00:00:00 2001 From: Cary Palmer Date: Mon, 14 Sep 2026 05:34:42 +0000 Subject: [PATCH] feat(mouth): Wave 7 P1 history hygiene + hop mayAddress grants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop/failMouth/failComputer terminalize in-flight mouth-stream arcs; scrubIncompleteToolPairs drops dangling provider tool pairs without inventing results or deleting feed rows. Optional mayAddressIds on agent profile (and room data) — offerSisterHop/sendToAgent spoken refuse + pendingLedger audit. Patterns only; no CopilotKit; no release tag. --- AGENTS.md | 1 + docs/rooms.md | 8 + docs/wave7-p0-openbot-parity.md | 4 +- docs/wave7-p1-history-hop-grants.md | 54 ++++++ src/app.tsx | 11 +- src/domain.ts | 16 +- src/runtime/factory.ts | 2 + src/runtime/history-sanitize.ts | 129 +++++++++++++ src/runtime/hop-grants.ts | 93 ++++++++++ src/runtime/profile.ts | 22 +++ src/runtime/rooms.ts | 9 + src/session.ts | 113 ++++++++++-- tests/domain.test.ts | 6 + tests/wave7-p1.test.ts | 277 ++++++++++++++++++++++++++++ 14 files changed, 726 insertions(+), 19 deletions(-) create mode 100644 docs/wave7-p1-history-hop-grants.md create mode 100644 src/runtime/history-sanitize.ts create mode 100644 src/runtime/hop-grants.ts create mode 100644 tests/wave7-p1.test.ts diff --git a/AGENTS.md b/AGENTS.md index ea120bb..98d54b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,6 +94,7 @@ Invariants: - GPUI polish P1 (Wave 6) — Cmd+K command palette lite (sisters / rooms / settings sections / Jobs); visible steer-queue card (remove / send-now); shared `ListRow` density + hover end-slot; `Tip` microcopy on titlebar / rail / Jobs; opaque menuFill overlays; idle parks intact; package 0.16.0. - GPUI polish P2 (Wave 6) — optional Mac OS notify (background mouth complete / needs approval; Quiet + `AUTOMATON_DISABLE_NOTIFICATIONS`); pulse lease strides (MouthWait / Activity slow cadence on expensive surfaces); feed edge-fade spike; Shortcuts help from live `KEYMAP`; `docs/wave6-gpui-polish.md` + idle-cpu honesty; no CopilotKit / gpui-kit; no version cut in P2. - Wave 7 P0 OpenBot parity — ToolLine mouth/hop feed cards; `ask_person` judgement exit; mouth silence watchdog + sticky stopped banner (`docs/wave7-p0-openbot-parity.md`); patterns only — no CopilotKit / `@ag-ui/*` / generative UI; package 0.16.0. +- Wave 7 P1 — Stop/interrupt history hygiene (`history-sanitize` terminalize + scrub incomplete tool pairs); hop `mayAddressIds` grants on profile/room with spoken refuse + ledger (`docs/wave7-p1-history-hop-grants.md`); patterns only — no CopilotKit / SPIRE; no version tag in this PR. - Bezel light appearance (Wave 2 P2) — designed light (Settings → Window → Appearance; not invert of graphite); Brand-complete Settings (remaining CARD/FIELD via useChrome; update modal scrim); ToggleGroup, EmptyState, Sheet extracted. Still vendored `@gpuix/react` (Wave 3.2 is 0.6.2 + matched native). Package 0.8.0. ## Safety diff --git a/docs/rooms.md b/docs/rooms.md index 517deb8..0c201d1 100644 --- a/docs/rooms.md +++ b/docs/rooms.md @@ -79,3 +79,11 @@ user never has to say “and?”. See `docs/peer-chase.md`. | Large fan-out (user path) | Confirm via `needsFanoutConfirm` / `pendingRoomPost` | | Peer-hop with `originUser` | Attended for Auto (person still watching) | | Peer-hop without `originUser` / routine | Unattended — Auto cannot swallow | + +## Hop grants (Wave 7 P1b) + +Optional `mayAddressIds` on an agent profile (and optionally a room) limits who +that seat may hop to. Missing = all visible sisters. Explicit `[]` = nobody. +`offerSisterHop` / `sendToAgent` speak a refuse and queue an action ledger row; +Settings UI can wait. See `docs/wave7-p1-history-hop-grants.md`. + diff --git a/docs/wave7-p0-openbot-parity.md b/docs/wave7-p0-openbot-parity.md index d03c3b6..c866ad1 100644 --- a/docs/wave7-p0-openbot-parity.md +++ b/docs/wave7-p0-openbot-parity.md @@ -58,9 +58,9 @@ JSON emit: ## Out of scope (P1+) -Interrupt history hygiene, hop `mayAddress` grants, hold cancelled-vs-refused, +Hold cancelled-vs-refused, computer activity epoch, proposed-sister consent — see -`artifacts/automaton-wave7-openbot-parity-audit.md`. +`artifacts/automaton-wave7-openbot-parity-audit.md`. History hygiene + hop grants: `docs/wave7-p1-history-hop-grants.md`. ## Tests diff --git a/docs/wave7-p1-history-hop-grants.md b/docs/wave7-p1-history-hop-grants.md new file mode 100644 index 0000000..48dc3f1 --- /dev/null +++ b/docs/wave7-p1-history-hop-grants.md @@ -0,0 +1,54 @@ +# Wave 7 P1 — Interrupt history hygiene + hop `mayAddress` grants + +**Status:** Implemented on branch `feat/wave-7-p1-history-hop-grants`. +**Policy:** Steal **patterns only** from CopilotKit/OpenBot. No `@ag-ui/*`, no +CopilotKit npm, no generative UI / sandboxed iframes. Mouths = OpenRouter; +Jobs = Puppetmaster. Native `@gpuix/react` only. Sisters stay separate. + +Do **not** cut a release tag in the P1 PR (v0.16.0 / v0.17.0 wait on coordinator). + +## P1a — History hygiene after Stop / interrupt + +| OpenBot | Automaton | +| --- | --- | +| `history-sanitize.ts` / drop dangling tool calls | `src/runtime/history-sanitize.ts` | +| UI transcript kept; provider seed filtered | Feed rows stay; terminal refuse appended | + +- On **Stop** / `failMouth` / `failComputer`: in-flight mouth-stream arcs + (`decide` / `act` without a later `done` / `refuse` for that tool+intent) + get a terminal **`refuse`** row with detail `Stopped.` (or the fail sentence). +- `scrubIncompleteToolPairs` drops unanswerable provider tool call/result + pairs from seeded history. Does **not** invent fake successful tool results. +- Person-visible feed rows are **never** deleted. +- Sticky `stoppedReason` (P0c) still parks until the next Send. + +## P1b — Hop `mayAddress` grants + +| OpenBot | Automaton | +| --- | --- | +| `mayAddress(from, to)` per hop + spoken refuse | `AgentProfile.mayAddressIds` + `Room.mayAddressIds` | +| `agent.handoff_refused` audit | `pendingLedger` → `action_events` (`hop` / `not_granted`) | + +- Optional allowlist on the **asking** agent profile (mirrored on live `Agent`). + Missing / undefined = **all visible sisters** (preserve current UX). + Explicit `[]` = nobody. +- Optional `Room.mayAddressIds` is data for Settings later (P1 stores + normalizes). +- `offerSisterHop` / `sendToAgent` check grants → spoken + `Not allowed to hand that to {Name}.` + ledger refuse (no throw mid-run). +- Not SPIRE / cluster leases / work-owner claiming. +- Settings UI can wait (P2); P1 is data + enforce. + +## P1c + +Absorbed into Wave 7 P0 hop ToolLine cards — no extra work here. + +## Out of scope (P2+) + +Hold cancelled-vs-refused, computer activity epoch auto-open, proposed-sister +consent, richer ledger event zoo — see +`artifacts/automaton-wave7-openbot-parity-audit.md`. + +## Tests + +`tests/wave7-p1.test.ts` — interrupt mid-act → terminal refuse → next send; +scrub dangling tool pairs; grant refuse spoken + ledger; default open grants. diff --git a/src/app.tsx b/src/app.tsx index 4313668..0234f36 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -135,6 +135,7 @@ import { failComputer, failJob, failMouth, + takePendingLedger, noteMouthNeed, noteJobStatus, patchLiveAgent, @@ -517,15 +518,19 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) { onComplete: (agentId, spoken) => { let routing: ChannelReplyRouting | undefined const emit = parseMouthEmit(spoken) + let hopLedger: ReturnType['events'] = [] setSession((current) => { if (current.threads[agentId]?.mouth === 'intro') markIntroPlayedAt(agentId) const taken = takePendingChannelReply(current, agentId) routing = taken.routing const next = completeMouth(taken.session, agentId, spoken) - bindNewUserAttachments(store, current, next) - persistIntroIfUserSpoke(next) - return next + const drained = takePendingLedger(next) + hopLedger = drained.events + bindNewUserAttachments(store, current, drained.session) + persistIntroIfUserSpoke(drained.session) + return drained.session }) + for (const event of hopLedger) store.recordAction(event) if (emit?.kind === 'ask_person') { store.recordAction( buildActionEvent({ diff --git a/src/domain.ts b/src/domain.ts index 31b24f2..c2faa91 100644 --- a/src/domain.ts +++ b/src/domain.ts @@ -20,6 +20,11 @@ export type Agent = { description: string color: string hidden: boolean + /** + * Optional hop allowlist (Wave 7 P1b). Missing = all visible sisters. + * Explicit [] = nobody. Mirrored from AgentProfile.mayAddressIds. + */ + mayAddressIds?: string[] } export type JobStatus = 'running' | 'waiting' | 'complete' | 'failed' @@ -1846,7 +1851,12 @@ export function formatSisterMandate(hop: Pick= HOP_MAX_DEPTH) return 'That hop is too deep.' if (handedThisTurn >= HOP_MAX_PER_TURN) return 'Already handed off enough this turn.' + // Missing grant list = open (current UX). Explicit list (incl. empty) enforces. + if (mayAddressIds !== undefined && mayAddressIds !== null && !mayAddressIds.includes(hop.to)) { + return `Not allowed to hand that to ${target.name}.` + } return null } diff --git a/src/runtime/factory.ts b/src/runtime/factory.ts index 33faf29..9ac6f66 100644 --- a/src/runtime/factory.ts +++ b/src/runtime/factory.ts @@ -75,6 +75,7 @@ export function liveAgentFromProfile(profile: AgentProfile): Agent { description: profile.description, color: catalogHex(profile.avatarColor || 'gray'), hidden: profile.hiddenFromRail, + ...(profile.mayAddressIds !== undefined ? { mayAddressIds: [...profile.mayAddressIds] } : {}), } } @@ -138,6 +139,7 @@ export function cloneAgent( homeRepo: source.homeRepo, homePath: source.homePath, introPlayedAt: null, + ...(source.mayAddressIds !== undefined ? { mayAddressIds: [...source.mayAddressIds] } : {}), } writeProfile(profile, home) ensureMarkFrames(profile.avatarShape, profile.avatarColor, home) diff --git a/src/runtime/history-sanitize.ts b/src/runtime/history-sanitize.ts new file mode 100644 index 0000000..168ed99 --- /dev/null +++ b/src/runtime/history-sanitize.ts @@ -0,0 +1,129 @@ +/** + * Wave 7 P1a — interrupt / Stop history hygiene (OpenBot history-sanitize pattern). + * + * Person-visible feed rows are never deleted. Provider seed is filtered so a + * half-finished tool pair cannot poison every later turn. + */ +import type { AgentId, FeedItem } from '../domain' +import { asMouthStreamPhase, type MouthStreamPhase } from './mouth-stream' + +/** In-flight mouth-stream phases (ToolLine still running). */ +export function isInFlightMouthStreamPhase(phase: MouthStreamPhase): boolean { + return phase === 'decide' || phase === 'act' +} + +export type InFlightMouthStreamArc = { + tool: string + intent: string + detail?: string + bytes?: number +} + +/** + * Latest mouth-stream arc per tool+intent whose phase is still decide/act. + * Does not mutate the feed — caller appends a terminal refuse/done row. + */ +export function findInFlightMouthStreamArcs( + items: readonly FeedItem[], +): InFlightMouthStreamArc[] { + const latest = new Map() + for (const item of items) { + if (item.kind !== 'mouth-stream') continue + const phase = asMouthStreamPhase(item.phase) + if (!phase) continue + const key = `${item.tool}\u0000${item.intent}` + latest.set(key, { + phase, + arc: { + tool: item.tool, + intent: item.intent, + detail: item.detail, + bytes: item.bytes, + }, + }) + } + const out: InFlightMouthStreamArc[] = [] + for (const row of latest.values()) { + if (isInFlightMouthStreamPhase(row.phase)) out.push(row.arc) + } + return out +} + +/** OpenAI / AG-UI-shaped seed message (mouth ChatTurn today has no tool pairs). */ +export type ProviderSeedMessage = { + role: 'system' | 'user' | 'assistant' | 'tool' | 'developer' + content?: string | null + /** Assistant tool calls awaiting results. */ + toolCalls?: { id: string }[] + /** Tool result keyed to a prior call. */ + toolCallId?: string +} + +function isSilentSeed(message: ProviderSeedMessage): boolean { + const content = message.content + if (content === undefined || content === null) return true + if (typeof content === 'string') return content.length === 0 + return false +} + +/** + * Drop unanswerable dangling tool calls from provider seed. + * Does not invent fake successful results. Does not mutate input. + * Healthy threads (no toolCalls) pass through as the same object references. + */ +export function scrubIncompleteToolPairs( + history: readonly ProviderSeedMessage[], + answeredElsewhere: ReadonlySet = new Set(), +): ProviderSeedMessage[] { + const boundaryAfter: number[] = new Array(history.length).fill(history.length) + for (let index = history.length - 1, next = history.length; index >= 0; index -= 1) { + boundaryAfter[index] = next + if (history[index]?.role === 'user') next = index + } + + const callAt = new Map() + for (const [index, message] of history.entries()) { + for (const call of message.toolCalls ?? []) { + if (!callAt.has(call.id)) callAt.set(call.id, index) + } + } + + const answerAt = new Map() + for (const [index, message] of history.entries()) { + const toolCallId = message.toolCallId + if (toolCallId === undefined || answerAt.has(toolCallId)) continue + const called = callAt.get(toolCallId) + if (called === undefined || index <= called) continue + if (index >= (boundaryAfter[called] ?? history.length)) continue + answerAt.set(toolCallId, index) + } + + const surviving = new Set() + const kept: (ProviderSeedMessage | undefined)[] = history.map((message) => { + const toolCalls = message.toolCalls + if (toolCalls === undefined) return message + + const answered = toolCalls.filter( + (call) => answeredElsewhere.has(call.id) || answerAt.has(call.id), + ) + for (const call of answered) surviving.add(call.id) + + if (answered.length === 0 && isSilentSeed(message)) return undefined + if (answered.length === toolCalls.length) return message + if (answered.length > 0) { + return { ...message, toolCalls: answered } + } + const { toolCalls: _drop, ...rest } = message + return rest + }) + + return kept.filter((message): message is ProviderSeedMessage => { + if (!message) return false + if (message.role === 'tool' && message.toolCallId) { + return surviving.has(message.toolCallId) + } + return true + }) +} + +export const HISTORY_STOP_DETAIL = 'Stopped.' diff --git a/src/runtime/hop-grants.ts b/src/runtime/hop-grants.ts new file mode 100644 index 0000000..6972f6d --- /dev/null +++ b/src/runtime/hop-grants.ts @@ -0,0 +1,93 @@ +/** + * Wave 7 P1b — hop mayAddress grants (OpenBot handoff mayAddress pattern). + * + * Optional allowlist on the asking agent's profile (and optionally a room). + * Missing / undefined = all visible sisters (preserve current UX). Explicit + * empty list = nobody. Read per hop, never cached across turns. + */ +import type { AgentId } from '../domain' +import { buildActionEvent, type ActionEvent } from './mouth-stream' +import type { AgentProfile } from './profile' +import type { Room } from './rooms' + +/** Normalize a grant list: trim, uniq, drop blanks. null/undefined stay open. */ +export function normalizeMayAddressIds( + raw: unknown, +): string[] | undefined { + if (raw === undefined || raw === null) return undefined + if (!Array.isArray(raw)) return undefined + const seen = new Set() + const out: string[] = [] + for (const item of raw) { + if (typeof item !== 'string') continue + const id = item.trim() + if (!id || seen.has(id)) continue + seen.add(id) + out.push(id) + } + return out +} + +/** + * Whether from may address to under optional grants. + * Undefined grants → allow (default all visible; visibility is checked elsewhere). + */ +export function mayAddress( + _fromId: AgentId, + toId: AgentId, + mayAddressIds?: readonly string[] | null, +): boolean { + if (mayAddressIds === undefined || mayAddressIds === null) return true + return mayAddressIds.includes(toId) +} + +/** Merge profile + optional room allowlists (intersection when both set). */ +export function effectiveMayAddressIds( + profileIds?: readonly string[] | null, + roomIds?: readonly string[] | null, +): string[] | undefined { + const profile = profileIds === null ? undefined : profileIds === undefined ? undefined : [...profileIds] + const room = roomIds === null ? undefined : roomIds === undefined ? undefined : [...roomIds] + if (profile === undefined && room === undefined) return undefined + if (profile === undefined) return room + if (room === undefined) return profile + const allow = new Set(room) + return profile.filter((id) => allow.has(id)) +} + +export function mayAddressFromProfile( + profile: Pick | null | undefined, + toId: AgentId, +): boolean { + return mayAddress('_', toId, profile?.mayAddressIds) +} + +export function mayAddressInRoom( + room: Pick | null | undefined, + toId: AgentId, +): boolean { + return mayAddress('_', toId, room?.mayAddressIds) +} + +/** Spoken refuse — short Automaton style, not a thrown error. */ +export function hopGrantRefusalLine(targetName: string): string { + const name = targetName.trim() || 'them' + return `Not allowed to hand that to ${name}.` +} + +/** Durable ledger row for a grant refuse (no secrets). */ +export function hopGrantRefusedEvent(input: { + fromId: AgentId + toId: AgentId + initiatorKind?: ActionEvent['initiatorKind'] +}): ActionEvent { + return buildActionEvent({ + ownerAgentId: input.fromId, + tool: 'hop', + intent: 'hand_off', + decision: 'refuse', + reason: 'not_granted', + path: input.toId, + initiatorKind: input.initiatorKind ?? 'person', + }) +} diff --git a/src/runtime/profile.ts b/src/runtime/profile.ts index ed76933..a418922 100644 --- a/src/runtime/profile.ts +++ b/src/runtime/profile.ts @@ -21,6 +21,11 @@ export type AgentProfile = { homeRepo: string homePath: string introPlayedAt?: string | null + /** + * Optional hop allowlist (Wave 7 P1b). Missing = all visible sisters. + * Explicit [] = nobody. Settings UI can wait; data + enforce in P1. + */ + mayAddressIds?: string[] } const KITS: AgentKit[] = ['coordinator', 'code', 'lookup', 'blank'] @@ -53,6 +58,7 @@ export function parseProfile(raw: unknown, fallbackId: string): AgentProfile { const skillIds = Array.isArray(row.skillIds) ? row.skillIds.filter((item): item is string => typeof item === 'string') : [] + const mayAddressIds = normalizeMayAddressField(row.mayAddressIds) return { id: typeof row.id === 'string' && row.id ? row.id : fallbackId, name: typeof row.name === 'string' ? row.name : 'New automaton', @@ -71,9 +77,25 @@ export function parseProfile(raw: unknown, fallbackId: string): AgentProfile { homePath: typeof row.homePath === 'string' ? row.homePath.trim() : '', introPlayedAt: typeof row.introPlayedAt === 'string' && row.introPlayedAt.trim() ? row.introPlayedAt : null, + ...(mayAddressIds !== undefined ? { mayAddressIds } : {}), } } +function normalizeMayAddressField(raw: unknown): string[] | undefined { + if (raw === undefined || raw === null) return undefined + if (!Array.isArray(raw)) return undefined + const seen = new Set() + const out: string[] = [] + for (const item of raw) { + if (typeof item !== 'string') continue + const id = item.trim() + if (!id || seen.has(id)) continue + seen.add(id) + out.push(id) + } + return out +} + export function readProfile(id: string, home = automatonHome()): AgentProfile | null { const path = profilePath(id, home) if (!existsSync(path)) return null diff --git a/src/runtime/rooms.ts b/src/runtime/rooms.ts index da4e42d..353d55b 100644 --- a/src/runtime/rooms.ts +++ b/src/runtime/rooms.ts @@ -11,6 +11,11 @@ export type Room = { archived?: boolean createdAt: string updatedAt: string + /** + * Optional hop allowlist for this room (Wave 7 P1b). Missing = all members + * addressable. Explicit [] = nobody. Settings UI can wait; data in P1. + */ + mayAddressIds?: AgentId[] } export type PeerProvenance = { @@ -79,6 +84,9 @@ function normalizeRoom(row: Record): Room | null { typeof row.createdAt === 'string' && row.createdAt.trim() ? row.createdAt.trim() : nowIso() const updatedAt = typeof row.updatedAt === 'string' && row.updatedAt.trim() ? row.updatedAt.trim() : createdAt + const mayAddressIds = Array.isArray(row.mayAddressIds) + ? uniqIds(row.mayAddressIds.filter((id): id is string => typeof id === 'string')) + : undefined return { id, name, @@ -86,6 +94,7 @@ function normalizeRoom(row: Record): Room | null { archived: row.archived === true ? true : undefined, createdAt, updatedAt, + ...(mayAddressIds !== undefined ? { mayAddressIds } : {}), } } diff --git a/src/session.ts b/src/session.ts index ad605f1..fa45c52 100644 --- a/src/session.ts +++ b/src/session.ts @@ -116,7 +116,16 @@ import { postToRoom, sanitizePeerRelay, } from './runtime/rooms' -import { mouthStreamFeedItem, type MouthStreamStep } from './runtime/mouth-stream' +import { mouthStreamFeedItem, type ActionEvent, type MouthStreamStep } from './runtime/mouth-stream' +import { + findInFlightMouthStreamArcs, + HISTORY_STOP_DETAIL, + scrubIncompleteToolPairs, +} from './runtime/history-sanitize' +import { hopGrantRefusedEvent, normalizeMayAddressIds } from './runtime/hop-grants' + +export { scrubIncompleteToolPairs, findInFlightMouthStreamArcs } from './runtime/history-sanitize' +export { mayAddress, hopGrantRefusalLine } from './runtime/hop-grants' export type ComputerWorkerStatus = 'running' | 'complete' | 'failed' | 'waiting_operator' @@ -173,6 +182,8 @@ export type Session = { pendingSend?: PendingSend /** Room fan-out awaiting needsFanoutConfirm (3+ members) when requireFanoutConfirm. */ pendingRoomPost?: { roomId: string; fromId: AgentId; text: string; targets: AgentId[]; home?: string } | null + /** Action ledger rows queued by pure session seams (hop refuse). App drains via takePendingLedger. */ + pendingLedger?: ActionEvent[] } /** Old snapshots omit goals and may still carry a worker mandate. */ @@ -1292,12 +1303,70 @@ export function pendingMouthTurns( return pending } -export function offerSisterHop(session: Session, hop: SisterHop): Session { +function queuePendingLedger(session: Session, event: ActionEvent): Session { + return { ...session, pendingLedger: [...(session.pendingLedger ?? []), event] } +} + +/** Drain queued ledger rows for the durable store. Clears pendingLedger. */ +export function takePendingLedger(session: Session): { session: Session; events: ActionEvent[] } { + const events = session.pendingLedger ?? [] + if (events.length === 0) return { session, events: [] } + const { pendingLedger: _drop, ...rest } = session + return { session: rest, events: [...events] } +} + +/** Resolve hop allowlist: live agent mirror, else profile on disk. Missing = open. */ +export function resolveMayAddressIds( + session: Session, + fromId: AgentId, + home?: string, +): string[] | undefined { + const live = session.agents.find((agent) => agent.id === fromId)?.mayAddressIds + if (live !== undefined) return normalizeMayAddressIds(live) + const profile = readProfile(fromId, home) + if (!profile || profile.mayAddressIds === undefined) return undefined + return normalizeMayAddressIds(profile.mayAddressIds) +} + +/** + * Mark in-flight mouth-stream arcs terminal (refuse + Stopped detail). + * Does not delete person-visible feed rows. Does not invent successful tool results. + */ +export function terminalizeInFlightMouthStreams( + session: Session, + agentId: AgentId, + detail = HISTORY_STOP_DETAIL, +): Session { + if (!session.threads[agentId]) return session + const arcs = findInFlightMouthStreamArcs(thread(session, agentId).items) + if (arcs.length === 0) return session + let next = session + const reason = detail.trim() || HISTORY_STOP_DETAIL + for (const arc of arcs) { + next = appendMouthStream(next, agentId, { + phase: 'refuse', + tool: arc.tool, + intent: arc.intent, + detail: reason, + bytes: arc.bytes, + }) + } + return next +} + +export function offerSisterHop(session: Session, hop: SisterHop, opts?: { home?: string }): Session { const focused = session.activeAgentId const fromRow = session.threads[hop.from] const handed = fromRow ? handedHopsSinceLastUser(fromRow.items) : 0 - const reason = sisterHopRefusal(hop, visibleAgents(session.agents), handed) - if (reason) return speak(session, hop.from, reason, focused) + const grants = resolveMayAddressIds(session, hop.from, opts?.home) + const reason = sisterHopRefusal(hop, visibleAgents(session.agents), handed, grants) + if (reason) { + let next = speak(session, hop.from, reason, focused) + if (grants !== undefined && !grants.includes(hop.to)) { + next = queuePendingLedger(next, hopGrantRefusedEvent({ fromId: hop.from, toId: hop.to })) + } + return next + } const name = session.agents.find((agent) => agent.id === hop.to)?.name ?? 'them' const marker = { to: hop.to, depth: hop.depth } const mandate = formatSisterMandate(hop) @@ -1440,8 +1509,9 @@ function coordinatorReturn( export function stopMouth(session: Session, agentId: AgentId): Session { if (!session.threads[agentId]) return session const mouth = thread(session, agentId).mouth - if (mouth === 'idle') return session - return setThread(session, agentId, { mouth: 'idle' }) + let next = terminalizeInFlightMouthStreams(session, agentId, HISTORY_STOP_DETAIL) + if (mouth === 'idle') return next + return setThread(next, agentId, { mouth: 'idle' }) } /** Composer Stop: idle the focused mouth and abandon every running job in the session. */ @@ -1460,25 +1530,26 @@ export function failMouth(session: Session, agentId: AgentId, spoken: string): S const row = thread(session, agentId) const last = row.items.at(-1) const reason = sanitizeSpeak(spoken).trim() + let base = terminalizeInFlightMouthStreams(session, agentId, reason || HISTORY_STOP_DETAIL) if (row.mouth === 'answer' && last?.kind === 'relay' && last.lane === 'from') { - const name = session.agents.find((agent) => agent.id === last.peerId)?.name ?? 'They' - let next = wakeMouth(session, agentId, 'idle') + const name = base.agents.find((agent) => agent.id === last.peerId)?.name ?? 'They' + let next = wakeMouth(base, agentId, 'idle') next = speak(next, agentId, returnBeat(name, last.text), session.activeAgentId) next = setStoppedReason(next, agentId, reason) return finishBatch(next, agentId) } - const from = inboundCoordinatorId(session, agentId) + const from = inboundCoordinatorId(base, agentId) if (from && row.mouth !== 'idle') { - const focused = session.activeAgentId + const focused = base.activeAgentId const line = reason - let next = session + let next = base if (line) next = speak(next, agentId, line, focused) next = wakeMouth(next, agentId, 'idle') next = coordinatorReturn(next, from, agentId, line, focused, true) next = setStoppedReason(next, agentId, reason) return finishBatch(next, agentId) } - let next = completeMouth(session, agentId, spoken) + let next = completeMouth(base, agentId, spoken) return setStoppedReason(next, agentId, reason) } @@ -1849,7 +1920,15 @@ function finishComputer( if (!worker) return session if (worker.status !== 'running' && worker.status !== 'waiting_operator') return session const focused = session.activeAgentId - let next = putComputer(session, { ...worker, status, screenshotPath }) + let next = session + if (status === 'failed') { + next = terminalizeInFlightMouthStreams( + next, + worker.ownerAgentId, + sanitizeSpeak(spoken).trim() || HISTORY_STOP_DETAIL, + ) + } + next = putComputer(next, { ...worker, status, screenshotPath }) next = speak(next, worker.ownerAgentId, sanitizeSpeak(spoken), focused) next = setComputerBusy(next, worker.ownerAgentId, liveComputerOn(next, worker.ownerAgentId)) next = wakeMouth(next, worker.ownerAgentId, 'idle') @@ -2294,11 +2373,19 @@ export function sendToAgent( fromId: AgentId, toId: AgentId, text: string, + opts?: { home?: string }, ): Session { const body = text.trim() if (!body) return session if (fromId === toId) return session if (!session.threads[fromId] || !session.threads[toId]) return session + const grants = resolveMayAddressIds(session, fromId, opts?.home) + if (grants !== undefined && !grants.includes(toId)) { + const name = session.agents.find((agent) => agent.id === toId)?.name ?? 'them' + let next = speak(session, fromId, `Not allowed to hand that to ${name}.`, session.activeAgentId) + next = queuePendingLedger(next, hopGrantRefusedEvent({ fromId, toId })) + return next + } const note = sanitizePeerRelay(body) || body const provenance = peerProvenanceForHop(session, fromId) const focused = session.activeAgentId diff --git a/tests/domain.test.ts b/tests/domain.test.ts index 825b90d..6268998 100644 --- a/tests/domain.test.ts +++ b/tests/domain.test.ts @@ -847,6 +847,12 @@ describe('typed sister hop', () => { 'Already handed off enough this turn.', ) expect(sisterHopRefusal(kernel, visible, 0)).toBeNull() + expect(sisterHopRefusal(kernel, visible, 0, undefined)).toBeNull() + expect(sisterHopRefusal(kernel, visible, 0, ['kernel'])).toBeNull() + expect(sisterHopRefusal(kernel, visible, 0, ['research'])).toBe( + 'Not allowed to hand that to Kernel.', + ) + expect(sisterHopRefusal(kernel, visible, 0, [])).toBe('Not allowed to hand that to Kernel.') }) test('depth and per-turn counts read hop markers on the thread', () => { diff --git a/tests/wave7-p1.test.ts b/tests/wave7-p1.test.ts new file mode 100644 index 0000000..a77e469 --- /dev/null +++ b/tests/wave7-p1.test.ts @@ -0,0 +1,277 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { + emptyThreads, + resetIdsForTests, + sisterHopRefusal, + staffWithSisters, + type Agent, +} from '../src/domain' +import { + findInFlightMouthStreamArcs, + scrubIncompleteToolPairs, + type ProviderSeedMessage, +} from '../src/runtime/history-sanitize' +import { + effectiveMayAddressIds, + mayAddress, + normalizeMayAddressIds, +} from '../src/runtime/hop-grants' +import { parseProfile } from '../src/runtime/profile' +import { + appendMouthStream, + failMouth, + offerSisterHop, + send, + sendToAgent, + stopRun, + takePendingLedger, + terminalizeInFlightMouthStreams, + type Session, +} from '../src/session' +import { buildWorkingSet as buildWs } from '../src/runtime/working-set' + +const root = join(import.meta.dir, '..') + +function fresh(): Session { + resetIdsForTests() + const agents = staffWithSisters() + return { + agents, + activeAgentId: 'staff', + threads: emptyThreads(agents), + jobs: [], + pendingFanout: null, + } +} + +function withMouth(session: Session, agentId: string, mouth: 'answer' | 'idle' | 'working'): Session { + const row = session.threads[agentId] + if (!row) return session + return { ...session, threads: { ...session.threads, [agentId]: { ...row, mouth } } } +} + +describe('wave 7 p1a history hygiene', () => { + test('findInFlightMouthStreamArcs sees decide/act without done/refuse', () => { + let session = fresh() + session = appendMouthStream(session, 'kernel', { + phase: 'decide', + tool: 'box_shell', + intent: 'shell', + detail: '/tmp', + }) + session = appendMouthStream(session, 'kernel', { + phase: 'act', + tool: 'box_shell', + intent: 'shell', + detail: '/tmp', + }) + const arcs = findInFlightMouthStreamArcs(session.threads.kernel!.items) + expect(arcs).toEqual([{ tool: 'box_shell', intent: 'shell', detail: '/tmp', bytes: undefined }]) + }) + + test('stopRun mid-act terminalizes refuse and keeps feed rows; next send works', () => { + let session = { ...fresh(), activeAgentId: 'kernel' as const } + session = send(session, 'run a shell') + session = withMouth(session, 'kernel', 'answer') + session = appendMouthStream(session, 'kernel', { + phase: 'decide', + tool: 'box_shell', + intent: 'shell', + }) + session = appendMouthStream(session, 'kernel', { + phase: 'act', + tool: 'box_shell', + intent: 'shell', + }) + const before = session.threads.kernel!.items.length + session = stopRun(session, 'kernel') + const items = session.threads.kernel!.items + expect(items.length).toBeGreaterThan(before) + expect(items.filter((row) => row.kind === 'mouth-stream').length).toBe(3) + const lastStream = [...items].reverse().find((row) => row.kind === 'mouth-stream') + expect(lastStream).toMatchObject({ + kind: 'mouth-stream', + phase: 'refuse', + tool: 'box_shell', + intent: 'shell', + detail: 'Stopped.', + }) + expect(findInFlightMouthStreamArcs(items)).toEqual([]) + expect(session.threads.kernel!.mouth).toBe('idle') + + // Next chat must succeed (working set builds; send parks answer). + session = send(session, 'hello again') + expect(session.threads.kernel!.mouth === 'answer' || session.threads.kernel!.mouth === 'ack').toBe(true) + const user = [...session.threads.kernel!.items] + .reverse() + .find((row) => row.kind === 'msg' && row.from === 'user' && row.text === 'hello again') + expect(user).toBeTruthy() + const ws = buildWs({ + agent: session.agents.find((a) => a.id === 'kernel')!, + thread: session.threads.kernel!, + claims: [], + query: 'hello again', + }) + expect(ws.some((turn) => turn.role === 'user')).toBe(true) + }) + + test('failMouth terminalizes in-flight arcs without deleting prior stream rows', () => { + let session = withMouth(fresh(), 'staff', 'answer') + session = appendMouthStream(session, 'staff', { phase: 'decide', tool: 'host_shell', intent: 'shell' }) + session = appendMouthStream(session, 'staff', { phase: 'act', tool: 'host_shell', intent: 'shell' }) + const priorIds = session.threads.staff!.items + .filter((row) => row.kind === 'mouth-stream') + .map((row) => row.id) + session = failMouth(session, 'staff', "Couldn't reach OpenRouter.") + const streams = session.threads.staff!.items.filter((row) => row.kind === 'mouth-stream') + expect(streams.map((row) => row.id).slice(0, 2)).toEqual(priorIds) + expect(streams.at(-1)).toMatchObject({ phase: 'refuse', tool: 'host_shell' }) + }) + + test('scrubIncompleteToolPairs drops dangling calls; does not invent results', () => { + const history: ProviderSeedMessage[] = [ + { role: 'user', content: 'do it' }, + { + role: 'assistant', + content: '', + toolCalls: [{ id: 'call_1' }, { id: 'call_2' }], + }, + { role: 'tool', toolCallId: 'call_1', content: 'ok' }, + // call_2 never answered — interrupt poison + { role: 'user', content: 'retry' }, + ] + const scrubbed = scrubIncompleteToolPairs(history) + const assistant = scrubbed.find((row) => row.role === 'assistant' && row.toolCalls) + expect(assistant?.toolCalls).toEqual([{ id: 'call_1' }]) + expect(scrubbed.some((row) => row.toolCallId === 'call_2')).toBe(false) + expect(scrubbed.some((row) => row.toolCallId === 'call_1')).toBe(true) + // No fake tool result invented for call_2 + expect(scrubbed.filter((row) => row.role === 'tool')).toHaveLength(1) + }) + + test('scrub leaves healthy history as same object refs', () => { + const history: ProviderSeedMessage[] = [ + { role: 'system', content: 'hi' }, + { role: 'user', content: 'hello' }, + { role: 'assistant', content: 'hey' }, + ] + const scrubbed = scrubIncompleteToolPairs(history) + expect(scrubbed).toHaveLength(3) + expect(scrubbed[0]).toBe(history[0]) + expect(scrubbed[1]).toBe(history[1]) + expect(scrubbed[2]).toBe(history[2]) + }) + + test('terminalize is a no-op when arcs already terminal', () => { + let session = fresh() + session = appendMouthStream(session, 'kernel', { phase: 'decide', tool: 'box_shell', intent: 'shell' }) + session = appendMouthStream(session, 'kernel', { phase: 'done', tool: 'box_shell', intent: 'shell' }) + const before = session.threads.kernel!.items.length + session = terminalizeInFlightMouthStreams(session, 'kernel') + expect(session.threads.kernel!.items.length).toBe(before) + }) +}) + +describe('wave 7 p1b hop mayAddress grants', () => { + test('default missing grants preserve open UX', () => { + expect(mayAddress('staff', 'kernel')).toBe(true) + expect(mayAddress('staff', 'kernel', undefined)).toBe(true) + expect(mayAddress('staff', 'kernel', null)).toBe(true) + expect(sisterHopRefusal( + { from: 'staff', to: 'kernel', task: 'Check.', depth: 0 }, + staffWithSisters(), + 0, + )).toBeNull() + expect(sisterHopRefusal( + { from: 'staff', to: 'kernel', task: 'Check.', depth: 0 }, + staffWithSisters(), + 0, + undefined, + )).toBeNull() + }) + + test('explicit allowlist refuses unspoken targets with spoken line + ledger', () => { + let session = fresh() + session = { + ...session, + agents: session.agents.map((agent) => + agent.id === 'staff' ? { ...agent, mayAddressIds: ['research'] } : agent, + ), + } + session = withMouth(session, 'staff', 'answer') + session = offerSisterHop(session, { + from: 'staff', + to: 'kernel', + task: 'Check the pin.', + depth: 0, + }) + expect(session.threads.staff!.items.at(-1)).toMatchObject({ + text: 'Not allowed to hand that to Kernel.', + }) + expect(session.threads.kernel!.mouth).toBe('idle') + const { session: drained, events } = takePendingLedger(session) + expect(drained.pendingLedger).toBeUndefined() + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + tool: 'hop', + intent: 'hand_off', + decision: 'refuse', + reason: 'not_granted', + path: 'kernel', + ownerAgentId: 'staff', + }) + }) + + test('grant allowlist still permits listed sisters', () => { + let session = fresh() + session = { + ...session, + agents: session.agents.map((agent) => + agent.id === 'staff' ? { ...agent, mayAddressIds: ['kernel'] } : agent, + ), + } + session = offerSisterHop(session, { + from: 'staff', + to: 'kernel', + task: 'Check the pin.', + depth: 0, + }) + expect(session.threads.staff!.items.some((row) => row.kind === 'msg' && row.text === 'Handed to Kernel.')).toBe( + true, + ) + expect(takePendingLedger(session).events).toEqual([]) + }) + + test('sendToAgent enforces grants with spoken refuse', () => { + let session = fresh() + session = { + ...session, + agents: session.agents.map((agent): Agent => + agent.id === 'staff' ? { ...agent, mayAddressIds: [] } : agent, + ), + } + session = sendToAgent(session, 'staff', 'kernel', 'Please look') + expect(session.threads.staff!.items.at(-1)).toMatchObject({ + text: 'Not allowed to hand that to Kernel.', + }) + expect(session.threads.kernel!.items.some((row) => row.kind === 'agent_note')).toBe(false) + expect(takePendingLedger(session).events[0]?.reason).toBe('not_granted') + }) + + test('profile parse hydrates mayAddressIds; normalize trims', () => { + const parsed = parseProfile({ name: 'Scout', mayAddressIds: [' kernel ', 'kernel', ''] }, 'agent_9') + expect(parsed.mayAddressIds).toEqual(['kernel']) + expect(normalizeMayAddressIds(['a', ' a ', 'b'])).toEqual(['a', 'b']) + expect(effectiveMayAddressIds(['kernel', 'research'], ['kernel'])).toEqual(['kernel']) + expect(effectiveMayAddressIds(undefined, ['kernel'])).toEqual(['kernel']) + }) + + test('docs note exists and policy forbids CopilotKit', () => { + const doc = readFileSync(join(root, 'docs/wave7-p1-history-hop-grants.md'), 'utf8') + expect(doc).toContain('mayAddress') + expect(doc).toContain('history-sanitize') + expect(doc.toLowerCase()).toContain('no `@ag-ui') + }) +})