Skip to content
Open
4 changes: 3 additions & 1 deletion hub/src/overseer/converse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ export async function runOverseerConverse(params: {
continue
}
try {
const result = runOverseerTool(overseer, name, args)
// The conversational surface is the operator-directed write-path, so dispositions
// are allowed here (gated off on the raw HTTP tool-dispatch endpoint).
const result = runOverseerTool(overseer, name, args, true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope disposition writes to the authenticated namespace

On authenticated /overseer/converse requests, this enables writes against the single global OverseerEntity without passing or checking c.get('namespace'). Because its inbox queries are global as well, an operator authenticated to namespace A can discover an item belonging to namespace B and have record_disposition mutate it by ID. Thread the authenticated namespace into the entity/tool call and reject items whose related session is outside it.

AGENTS.md reference: AGENTS.md:L149-L149

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to epic #107 (namespace scoping for Overseer inbox/disposition writes). Not in scope for this PR per operator triage.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferred to #107 (namespace substrate epic). Not blocking single-tenant dogfood.

Comment thread
heavygee marked this conversation as resolved.
Comment on lines +125 to +127

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Defer writes emitted alongside unresolved read calls

When the model emits multiple tool calls in one assistant message, this loop executes every call before returning any result to the model. For an operator request such as “dismiss the PR-flood one,” the model can emit query_inbox plus record_disposition in the same batch; the write then runs with a guessed item ID because the model has not seen the query result, despite the prompt requiring it to identify the item first. Reject or defer write calls in a batch containing unresolved reads so record_disposition must occur in a later iteration after the identifying result is available.

Useful? React with 👍 / 👎.

toolTrace.push({ tool: name, args, ok: true })
Comment thread
heavygee marked this conversation as resolved.
// The brain opts into 'full' per call when it needs depth; default lean.
const detail = args.detail === 'full' ? 'full' : 'lean'
Expand Down
30 changes: 26 additions & 4 deletions hub/src/overseer/runOverseerTool.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,34 @@
import {
OVERSEER_TOOL_NAMES,
isOverseerWriteTool,
overseerToolArgsSchemas,
type OverseerToolName
} from '@hapi/protocol'
import type { OverseerEntity } from '../sync/overseerEntity'

/** Thrown when a write tool (`record_disposition`) is dispatched on a read-only surface (R2 gate). */
export class OverseerWriteNotAllowedError extends Error {
constructor(tool: string) {
super(`Tool "${tool}" writes and is not allowed on this surface`)
this.name = 'OverseerWriteNotAllowedError'
}
}

/**
* Execute one read-only Overseer tool by name against the entity. Shared by the
* HTTP tool-dispatch route and the converse tool-calling loop so both go through
* exactly one place. Throws `ZodError` on invalid args; every tool is read-only.
* Execute one Overseer tool by name against the entity. Shared by the HTTP tool-dispatch route and
* the converse tool-calling loop so both go through exactly one place. Throws `ZodError` on invalid
* args. Every tool is read-only EXCEPT `record_disposition`; writes are gated behind `allowWrites`
* (the conversational path sets it; the raw HTTP dispatch does not).
*/
export function runOverseerTool(overseer: OverseerEntity, tool: OverseerToolName, args: unknown): unknown {
export function runOverseerTool(
overseer: OverseerEntity,
tool: OverseerToolName,
args: unknown,
allowWrites = false
): unknown {
if (isOverseerWriteTool(tool) && !allowWrites) {
throw new OverseerWriteNotAllowedError(tool)
}
switch (tool) {
case 'query_events':
return { events: overseer.queryEvents(overseerToolArgsSchemas.query_events.parse(args)) }
Expand All @@ -36,6 +54,10 @@ export function runOverseerTool(overseer: OverseerEntity, tool: OverseerToolName
return { workers: overseer.listActiveWorkers(overseerToolArgsSchemas.list_active_workers.parse(args)) }
case 'query_open_loops':
return overseer.queryOpenLoops(overseerToolArgsSchemas.query_open_loops.parse(args))
case 'query_dispositions':
return overseer.queryDispositions(overseerToolArgsSchemas.query_dispositions.parse(args))
case 'record_disposition':
return overseer.recordDisposition(overseerToolArgsSchemas.record_disposition.parse(args))
default: {
const exhaustive: never = tool
throw new Error(`Unknown overseer tool: ${String(exhaustive)}`)
Expand Down
36 changes: 36 additions & 0 deletions hub/src/overseer/toolProjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,4 +115,40 @@ describe('projectToolResultForBrain', () => {
const explanation = { explanation: { inboxItemId: 1, title: 'x' } }
expect(projectToolResultForBrain('explain_priority', explanation)).toBe(explanation)
})

it('thins query_dispositions list rows to predicate keys + as-seen title', () => {
const raw = {
mode: 'list',
total: 1,
rows: [
{
id: 9,
itemId: 42,
action: 'done',
statusAfter: 'resolved',
feedback: 'y'.repeat(500),
createdAt: 5,
sourceKind: 'worker',
eventType: 'needs_decision',
category: 'QUESTION',
project: 'hapi',
repo: 'tiann/hapi',
title: 'x'.repeat(500)
}
]
}
const projected = projectToolResultForBrain('query_dispositions', raw) as {
rows: { statusAfter?: unknown; what: string; feedback: string; itemId: number }[]
}
expect(projected.rows[0]?.itemId).toBe(42)
// fat dropped, title/feedback truncated
expect(projected.rows[0]?.statusAfter).toBeUndefined()
expect((projected.rows[0]?.what as string).length).toBeLessThan(100)
expect((projected.rows[0]?.feedback as string).length).toBeLessThan(140)
})

it('passes query_dispositions cluster mode through untouched (already tiny)', () => {
const raw = { mode: 'cluster', total: 1, clusters: [{ keys: { category: 'QUESTION' }, count: 3, actions: { done: 3 }, lastCreatedAt: 9 }] }
expect(projectToolResultForBrain('query_dispositions', raw)).toBe(raw)
})
})
21 changes: 21 additions & 0 deletions hub/src/overseer/toolProjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,20 @@ function projectInboxItem(item: unknown): Record<string, unknown> {
return { id: o.id, what: o.title, status: o.status, priority: o.priority }
}

/** Disposition list row → the predicate keys + as-seen title (the rest is one query away). */
function projectDispositionRow(row: unknown): Record<string, unknown> {
const o = isObj(row) ? row : {}
return {
itemId: o.itemId,
action: o.action,
category: o.category,
project: o.project,
eventType: o.eventType,
what: truncate(o.title, 80),
feedback: truncate(o.feedback, 120)
}
}

function len(value: unknown): number | undefined {
return Array.isArray(value) ? value.length : undefined
}
Expand Down Expand Up @@ -149,6 +163,13 @@ export function projectToolResultForBrain(
if (tool === 'query_open_loops' && isObj(result) && Array.isArray(result.openLoops)) {
return { counts: result.counts, openLoops: result.openLoops.map(projectOpenLoop) }
}
if (tool === 'query_dispositions' && isObj(result)) {
// cluster mode is already tiny (key tuples + counts); only thin list rows.
if (Array.isArray(result.rows)) {
return { mode: result.mode, total: result.total, rows: result.rows.map(projectDispositionRow) }
}
return result
}
if (tool === 'get_session_state' && isObj(result) && 'state' in result) {
return { state: result.state == null ? null : projectSessionState(result.state) }
}
Expand Down
111 changes: 111 additions & 0 deletions hub/src/store/inboxItems.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { buildOverseerSessionIdentity, mergeEventPayloadWithSession } from '@hap
import { Store } from './index'
import type { StoredSession } from './types'
import { deleteSession } from './sessions'
import { ensureOverseerInboxSchema } from './inboxItems'
import { Database } from 'bun:sqlite'

function payloadForSession(session: StoredSession, extra: Record<string, unknown> = {}): string {
Expand All @@ -28,6 +29,36 @@ describe('Overseer inbox schema (init-gated, not SCHEMA_VERSION)', () => {
expect(names.has('inbox_operator_actions')).toBe(true)
})

it('adds the R8 disposition snapshot columns to a pre-existing inbox_operator_actions table', () => {
const db = new Database(':memory:')
db.exec('PRAGMA foreign_keys = ON')
// Simulate the live DB shape BEFORE the keystone: the old 6-column table.
db.exec(`
CREATE TABLE inbox_operator_actions (
id INTEGER PRIMARY KEY,
inbox_item_id INTEGER NOT NULL,
action TEXT NOT NULL,
status_after TEXT NOT NULL,
feedback TEXT,
created_at INTEGER NOT NULL
);
`)
db.exec("INSERT INTO inbox_operator_actions (inbox_item_id, action, status_after, feedback, created_at) VALUES (1, 'done', 'resolved', NULL, 1000)")

ensureOverseerInboxSchema(db)

const cols = new Set(
(db.prepare('PRAGMA table_info(inbox_operator_actions)').all() as { name: string }[]).map((c) => c.name)
)
for (const col of ['source_kind', 'source_ref', 'event_type', 'category', 'project', 'artifact_kind', 'repo', 'context_snapshot_json']) {
expect(cols.has(col)).toBe(true)
}
// Pre-existing row survives with NULL snapshot; migration is idempotent on re-run.
expect((db.prepare('SELECT COUNT(*) AS n FROM inbox_operator_actions').get() as { n: number }).n).toBe(1)
expect(() => ensureOverseerInboxSchema(db)).not.toThrow()
db.close()
})

it('promotes attention events into one active item per session', () => {
const store = new Store(':memory:')
const session = store.sessions.getOrCreateSession('inbox-promo', { flavor: 'codex', name: 'peer-x' }, null, 'default')
Expand Down Expand Up @@ -227,4 +258,84 @@ describe('Overseer inbox schema (init-gated, not SCHEMA_VERSION)', () => {
expect(after?.title).toBe('meta HAPI triage')
expect(after?.relatedSessionId).toBeNull()
})

it('hides sleeping snoozes by default but returns them when statuses includes snoozed', () => {
const store = new Store(':memory:')
const session = store.sessions.getOrCreateSession('snooze-q', { name: 's' }, null, 'default')
const event = store.events.insert({
ts: 1000,
sourceKind: 'worker',
eventType: 'blocked',
attentionCandidate: 1,
summary: 'blocked',
relatedSessionId: session.id,
payloadJson: payloadForSession(session),
provenance: 'test'
})
const item = store.inbox.promoteAttentionEvent(event!)!
const wake = Date.now() + 60_000
store.inbox.recordOperatorAction(item.id, 'snooze', null, wake)

expect(store.inbox.list({ activeOnly: true }).map((i) => i.id)).not.toContain(item.id)
expect(store.inbox.list({
statuses: ['snoozed'],
includeSleepingSnoozed: true
}).map((i) => i.id)).toContain(item.id)
})

it('promotion during an active snooze updates the same item instead of inserting a duplicate', () => {
const store = new Store(':memory:')
const session = store.sessions.getOrCreateSession('snooze-dedup', { name: 's' }, null, 'default')
const first = store.events.insert({
ts: 1000,
sourceKind: 'worker',
eventType: 'blocked',
attentionCandidate: 1,
summary: 'first',
relatedSessionId: session.id,
payloadJson: payloadForSession(session),
provenance: 'test'
})
const item = store.inbox.promoteAttentionEvent(first!)!
store.inbox.recordOperatorAction(item.id, 'snooze', null, Date.now() + 60_000)

const second = store.events.insert({
ts: 2000,
sourceKind: 'worker',
eventType: 'needs_decision',
attentionCandidate: 1,
summary: 'second while snoozed',
relatedSessionId: session.id,
payloadJson: payloadForSession(session),
provenance: 'test'
})
const again = store.inbox.promoteAttentionEvent(second!)!
expect(again.id).toBe(item.id)
expect(store.inbox.list({ statuses: ['new', 'surfaced', 'deferred', 'snoozed'] }).filter((i) => i.relatedSessionId === session.id)).toHaveLength(1)
})

it('disposition snapshot uses title-priority artifact kind (PR over generic URL)', () => {
const store = new Store(':memory:')
const session = store.sessions.getOrCreateSession('art-prio', { name: 's' }, null, 'default')
const refs = JSON.stringify([
{ kind: 'url', url: 'https://example.com', title: 'generic' },
{ kind: 'github_pr', ref: 'heavygee/hapi#102', title: 'fix: snooze', repo: 'heavygee/hapi' }
])
const event = store.events.insert({
ts: 1000,
sourceKind: 'worker',
eventType: 'needs_review',
attentionCandidate: 1,
summary: 'review',
relatedSessionId: session.id,
artifactRefs: refs,
payloadJson: payloadForSession(session),
provenance: 'test'
})
const item = store.inbox.promoteAttentionEvent(event!)!
store.inbox.recordOperatorAction(item.id, 'done', null, null)
const rows = store.inbox.listDispositions({ limit: 5 })
expect(rows[0]?.artifactKind).toBe('github_pr')
expect(rows[0]?.repo).toBe('heavygee/hapi')
})
})
Loading
Loading