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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/rooms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

4 changes: 2 additions & 2 deletions docs/wave7-p0-openbot-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
54 changes: 54 additions & 0 deletions docs/wave7-p1-history-hop-grants.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 8 additions & 3 deletions src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ import {
failComputer,
failJob,
failMouth,
takePendingLedger,
noteMouthNeed,
noteJobStatus,
patchLiveAgent,
Expand Down Expand Up @@ -517,15 +518,19 @@ function StaffApp({ store: providedStore }: { store?: StaffStore } = {}) {
onComplete: (agentId, spoken) => {
let routing: ChannelReplyRouting | undefined
const emit = parseMouthEmit(spoken)
let hopLedger: ReturnType<typeof takePendingLedger>['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({
Expand Down
16 changes: 15 additions & 1 deletion src/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -1846,14 +1851,23 @@ export function formatSisterMandate(hop: Pick<SisterHop, 'task' | 'constraints'
return lines.join('\n')
}

export function sisterHopRefusal(hop: SisterHop, visible: Agent[], handedThisTurn: number): string | null {
export function sisterHopRefusal(
hop: SisterHop,
visible: Agent[],
handedThisTurn: number,
mayAddressIds?: readonly string[] | null,
): string | null {
if (!hop.task.trim()) return 'Need a task to hand off.'
if (hop.to === hop.from) return 'Cannot hand that to myself.'
if (hop.to === 'staff') return 'Staff is not a hop target.'
const target = visible.find((agent) => agent.id === hop.to)
if (!target) return 'That automaton is not on the rail.'
if (hop.depth >= 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
}

Expand Down
2 changes: 2 additions & 0 deletions src/runtime/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] } : {}),
}
}

Expand Down Expand Up @@ -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)
Expand Down
129 changes: 129 additions & 0 deletions src/runtime/history-sanitize.ts
Original file line number Diff line number Diff line change
@@ -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<string, { phase: MouthStreamPhase; arc: InFlightMouthStreamArc }>()
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<string> = 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<string, number>()
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<string, number>()
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<string>()
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.'
93 changes: 93 additions & 0 deletions src/runtime/hop-grants.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
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<AgentProfile, 'mayAddressIds'> | null | undefined,
toId: AgentId,
): boolean {
return mayAddress('_', toId, profile?.mayAddressIds)
}

export function mayAddressInRoom(
room: Pick<Room, 'mayAddressIds'> | 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',
})
}
Loading
Loading