From 74acbcc030800761cee59a7f9f2ab606c5793f35 Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:32:29 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(overseer):=20query=5Fopen=5Floops=20?= =?UTF-8?q?=E2=80=94=20the=20"what=20am=20I=20forgetting=3F"=20lens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a read-only neglect-axis lens, orthogonal to inbox urgency. A cold open loop = a session whose latest status-bearing worker event is not `done` (needs_decision/needs_review/blocked/failed/stalled) and was never closed by a later `completed`. Self-populating from AGENT_NOTIFY_SUMMARY; no operator triage. - shared: 8th tool query_open_loops (name, zod schema, catalog, OpenAI params, OverseerOpenLoop return type); isNoOpAction + openLoopBucket + open-loop event-type constants. - store: latestWorkerStatusPerSession — one indexed query, latest status-bearing worker event per session (progress excluded; completed is the only closer). - entity: queryOpenLoops — drops closed loops, nulls no-op actions (status!=done is the strong filter, action is a tiebreak), buckets waiting_on_you before half_finished, coldest-first; identity survives session deletion via payload. - converse: brain-facing projection thins the rows; system prompt gains a two-axes section (urgency vs neglect) and fixes priority direction (lower-is-higher — the 27B called p50 "highest"). - docs: implementation notes + the zero-code weekly-digest converse prompt. - tests: entity lens (ordering/closed/no-op/filters), projection, shared helpers; tool count 7 -> 8. Co-authored-by: Cursor --- ...7-31-overseer-open-loops-implementation.md | 66 ++++++++++ hub/src/overseer/runOverseerTool.ts | 2 + hub/src/overseer/toolProjection.test.ts | 14 +++ hub/src/overseer/toolProjection.ts | 18 +++ hub/src/store/eventStore.ts | 6 + hub/src/store/events.ts | 28 +++++ hub/src/sync/overseerEntity.test.ts | 81 +++++++++++++ hub/src/sync/overseerEntity.ts | 114 ++++++++++++++++++ hub/src/web/routes/overseer.test.ts | 2 +- shared/src/overseerConverse.ts | 6 + shared/src/overseerEntity.ts | 67 +++++++++- shared/src/overseerEvents.test.ts | 30 +++++ shared/src/overseerEvents.ts | 67 ++++++++++ 13 files changed, 498 insertions(+), 3 deletions(-) create mode 100644 docs/plans/2026-07-31-overseer-open-loops-implementation.md diff --git a/docs/plans/2026-07-31-overseer-open-loops-implementation.md b/docs/plans/2026-07-31-overseer-open-loops-implementation.md new file mode 100644 index 0000000000..3de8194121 --- /dev/null +++ b/docs/plans/2026-07-31-overseer-open-loops-implementation.md @@ -0,0 +1,66 @@ +# Overseer "what am I forgetting?" — cold open-loops lens (implementation) + +> **Branch:** `feat/overseer-open-loops` (stacked on `feat/overseer-text-converse`). +> **Design + evidence:** [`2026-07-31-overseer-forgotten-open-loops-lens.md`](./2026-07-31-overseer-forgotten-open-loops-lens.md) +> (validated live against the 27B on `:3006`). This doc = what landed. + +The lens is the **neglect axis** — "what have I abandoned?" — orthogonal to the urgency axis +("what needs me now?", `query_inbox` + `explain_priority`). It is self-populating from the +`AGENT_NOTIFY_SUMMARY` each worker turn already emits; **no operator triage required**. + +## What landed (build path steps 1 + 2) + +### Step 2 — `query_open_loops` read-only tool + +An 8th read-only Overseer tool. Definition: + +> **A cold open loop** = a session whose *latest* status-bearing worker event is NOT `done` +> (`needs_decision` / `needs_review` / `blocked` / `failed` / `stalled`) and was never closed by +> a later `completed`. + +- **Substrate:** raw `events`, not the coalesced inbox (the design showed events surface ~10 real + forgotten decisions that never became inbox items). One indexed query takes the latest + status-bearing worker event **per session** (`progress` is excluded — a progress ping does not + close an operator-owed decision; `completed` is the only closer). +- **Strong filter:** `status != done`. A no-op `action` ("none"/"complete"/"n/a"/…) is nulled but + the loop still surfaces — action text is a *tiebreak*, not the filter (per the spec correction + that killed 108 Tier-B false positives). +- **Buckets:** `waiting_on_you` (needs_decision / needs_review — the operator owes a decision) is + presented **before** `half_finished` (blocked / failed / stalled). Each bucket is **coldest-first**. +- **Args:** `{ minAgeMs?, bucket?, project?, limit? }` — `minAgeMs` is the "went cold" knob (default 0, + raise it to focus on genuinely stale threads). +- **Returns:** `{ openLoops: [{ sessionId, name, project, flavor, status, eventType, eventId, action, + summary, lastTs, ageMs, ageDays, bucket }], counts: { total, waitingOnYou, halfFinished } }`. + The brain-facing projection thins this to `{ id, name, project, status, action, what, ageDays, bucket }`. + +### System-prompt changes (converse/entity layer) + +- **Two questions, two axes** section: urgency (`query_inbox`, priority-ordered) vs neglect + (`query_open_loops`, age-ordered). Tells the brain which tool answers "what am I forgetting?". +- **Priority direction fix:** priority is **lower-is-higher** (1 = most important). This corrects the + 27B's live mistake of calling p50 "highest". + +## Step 1 — zero-code weekly digest (works today, no substrate change) + +`query_open_loops` makes the "what am I forgetting?" converse prompt reliable. A scheduled weekly +digest can send this to `POST /api/overseer/converse` and post the reply once (never an interrupt): + +``` +What have I forgotten or abandoned? Use query_open_loops (minAgeMs = 3 days). Lead with the +"Waiting on You" bucket — decisions I owe — then half-finished work. For each, one line: what it is, +how many days cold, and the concrete next step (skip ones with no real next step). Do not rank by +priority; this is about neglect, not urgency. Keep it to the top ~15. +``` + +This doubles as a **triage bootstrap**: surface ~15 cold loops, operator dispositions them, and the +inbox disposition loop the spec always wanted becomes tractable. + +## Not in this branch (follow-ups) + +- **Archiving hygiene (step 3):** aggressive session archive + sweeping legacy `stale` + ("No agent output for 30 minutes") rows that predate the fix which stopped writing them + (`checkStaleSessions` already returns `[]`; those legacy rows are `source_kind=system` so + `query_open_loops` — worker-only — already excludes them from the lens, but they still bloat + Session Logs). Coordinate the sweep with the inbox-ingest lane. +- **Dependency:** inbox PR-title + priority-band fix (PR #99) lands on the urgency axis, independent + of this lens. diff --git a/hub/src/overseer/runOverseerTool.ts b/hub/src/overseer/runOverseerTool.ts index 9f8413b81f..31121f461b 100644 --- a/hub/src/overseer/runOverseerTool.ts +++ b/hub/src/overseer/runOverseerTool.ts @@ -34,6 +34,8 @@ export function runOverseerTool(overseer: OverseerEntity, tool: OverseerToolName } case 'list_active_workers': return { workers: overseer.listActiveWorkers(overseerToolArgsSchemas.list_active_workers.parse(args)) } + case 'query_open_loops': + return overseer.queryOpenLoops(overseerToolArgsSchemas.query_open_loops.parse(args)) default: { const exhaustive: never = tool throw new Error(`Unknown overseer tool: ${String(exhaustive)}`) diff --git a/hub/src/overseer/toolProjection.test.ts b/hub/src/overseer/toolProjection.test.ts index 19b848fddd..60045ddf41 100644 --- a/hub/src/overseer/toolProjection.test.ts +++ b/hub/src/overseer/toolProjection.test.ts @@ -67,6 +67,20 @@ describe('projectToolResultForBrain', () => { expect(JSON.stringify(lean)).not.toContain('flavor') }) + it('thins open loops to id/name/project/status/action/what/ageDays/bucket', () => { + const raw = { + counts: { total: 2, waitingOnYou: 1, halfFinished: 1 }, + openLoops: [ + { sessionId: 'a', name: 'peer-a', project: 'web', flavor: 'cursor', status: 'needs_decision', eventType: 'needs_decision', eventId: 5, action: 'choose target', summary: 'peer-a needs_decision', lastTs: 111, ageMs: 999, ageDays: 10, bucket: 'waiting_on_you' } + ] + } + const lean = projectToolResultForBrain('query_open_loops', raw) as { counts: unknown; openLoops: unknown[] } + expect(lean.counts).toEqual({ total: 2, waitingOnYou: 1, halfFinished: 1 }) + expect(lean.openLoops[0]).toEqual({ id: 'a', name: 'peer-a', project: 'web', status: 'needs_decision', action: 'choose target', what: 'peer-a needs_decision', ageDays: 10, bucket: 'waiting_on_you' }) + expect(JSON.stringify(lean)).not.toContain('eventId') + expect(JSON.stringify(lean)).not.toContain('lastTs') + }) + it('passes un-projected tools through untouched', () => { const state = { state: { sessionId: 'x', observedState: 'idle' } } expect(projectToolResultForBrain('get_session_state', state)).toBe(state) diff --git a/hub/src/overseer/toolProjection.ts b/hub/src/overseer/toolProjection.ts index aedd71c3a7..5c61ff15be 100644 --- a/hub/src/overseer/toolProjection.ts +++ b/hub/src/overseer/toolProjection.ts @@ -64,6 +64,21 @@ function projectWorker(worker: unknown): Record { } } +/** Open loop → the minimum for the "what am I forgetting?" answer. */ +function projectOpenLoop(loop: unknown): Record { + const o = isObj(loop) ? loop : {} + return { + id: o.sessionId, + name: o.name, + project: o.project, + status: o.status, + action: o.action, + what: o.summary, + ageDays: o.ageDays, + bucket: o.bucket + } +} + export function projectToolResultForBrain(tool: OverseerToolName, result: unknown): unknown { if (tool === 'query_events' && isObj(result) && Array.isArray(result.events)) { return { total: result.events.length, events: result.events.map(projectEvent) } @@ -71,6 +86,9 @@ export function projectToolResultForBrain(tool: OverseerToolName, result: unknow if (tool === 'list_active_workers' && isObj(result) && Array.isArray(result.workers)) { return { total: result.workers.length, workers: result.workers.map(projectWorker) } } + if (tool === 'query_open_loops' && isObj(result) && Array.isArray(result.openLoops)) { + return { counts: result.counts, openLoops: result.openLoops.map(projectOpenLoop) } + } if (tool === 'query_inbox' && isObj(result) && Array.isArray(result.items)) { // The raw result is {items, candidates, surfaced, held} — four arrays that // repeat the same rows (a big part of the ~75k-token bloat). We keep only diff --git a/hub/src/store/eventStore.ts b/hub/src/store/eventStore.ts index 4ba51848ce..be71dae4b5 100644 --- a/hub/src/store/eventStore.ts +++ b/hub/src/store/eventStore.ts @@ -6,6 +6,7 @@ import { insertSystemEvent, listSystemEvents, queryEvents, + queryLatestWorkerStatusPerSession, repointSessionEvents, type InsertSystemEventInput, type ListSystemEventsOptions, @@ -30,6 +31,11 @@ export class EventStore { return queryEvents(this.db, options) } + /** Latest status-bearing worker event per session (cold-open-loops substrate). */ + latestWorkerStatusPerSession(limit?: number): StoredSystemEvent[] { + return queryLatestWorkerStatusPerSession(this.db, limit) + } + getById(id: number): StoredSystemEvent | null { return getSystemEventById(this.db, id) } diff --git a/hub/src/store/events.ts b/hub/src/store/events.ts index 8e1cd3d41c..95d53df225 100644 --- a/hub/src/store/events.ts +++ b/hub/src/store/events.ts @@ -294,6 +294,34 @@ export function queryEvents(db: Database, options: QueryEventsOptions = {}): Sto return rows.map(mapRow) } +/** + * Latest status-bearing worker event per session — the substrate for the + * cold-open-loops lens. "Status-bearing" = the notify-derived types that either + * open a loop (needs_decision/needs_review/blocked/failed/stale) or close it + * (completed); `progress` is intentionally excluded so a progress ping does not + * mask an unanswered decision. The caller decides open vs closed by inspecting + * the returned event's type. Bounded and index-friendly (one row per session). + */ +export function queryLatestWorkerStatusPerSession(db: Database, limit = 500): StoredSystemEvent[] { + const cap = Math.min(Math.max(limit, 1), 2000) + const rows = db.prepare(` + SELECT e.* FROM events e + JOIN ( + SELECT related_session_id AS sid, MAX(id) AS max_id + FROM events + WHERE source_kind = 'worker' + AND related_session_id IS NOT NULL + AND event_type IN ( + 'needs_decision', 'needs_review', 'blocked', 'failed', 'stale', 'completed' + ) + GROUP BY related_session_id + ) latest ON e.id = latest.max_id + ORDER BY e.ts ASC + LIMIT ? + `).all(cap) as SystemEventRow[] + return rows.map(mapRow) +} + export function insertEventLink( db: Database, input: { diff --git a/hub/src/sync/overseerEntity.test.ts b/hub/src/sync/overseerEntity.test.ts index 0e22610587..b406936107 100644 --- a/hub/src/sync/overseerEntity.test.ts +++ b/hub/src/sync/overseerEntity.test.ts @@ -150,6 +150,87 @@ describe('OverseerEntity read-only tools', () => { expect(web[0]?.project).toBe('web') }) + it('query_open_loops surfaces cold non-done threads, waiting-on-you first, coldest-first', () => { + const store = new Store(':memory:') + const now = Date.now() + const day = 86_400_000 + const mkSession = (key: string, project: string, name: string) => + store.sessions.getOrCreateSession(key, { flavor: 'codex', path: `/tmp/${project}`, name }, null, 'default').id + const worker = (id: string, project: string, name: string, eventType: string, ts: number, action: string | null, status?: string) => { + store.events.insert({ + ts, sourceKind: 'worker', eventType, attentionCandidate: 1, severity: 4, + summary: `${name}: ${eventType}`, relatedSessionId: id, + payloadJson: JSON.stringify({ + notify_summary: { status: status ?? eventType, action, summary: `${name} ${eventType}` }, + suggested_action: action, + session: { id, project, name } + }) + }) + } + + const a = mkSession('a', 'web', 'peer-a') + const b = mkSession('b', 'api', 'peer-b') + const c = mkSession('c', 'web', 'peer-c') + const d = mkSession('d', 'ops', 'peer-d') + + // A: needs_decision 10d cold (waiting_on_you) + worker(a, 'web', 'peer-a', 'needs_decision', now - 10 * day, 'choose deploy target', 'needs_decision') + // B: blocked 5d cold (half_finished) + worker(b, 'api', 'peer-b', 'blocked', now - 5 * day, 'fix CI auth', 'blocked') + // C: needs_decision 3d then completed 1d -> loop CLOSED, excluded + worker(c, 'web', 'peer-c', 'needs_decision', now - 3 * day, 'pick lib', 'needs_decision') + worker(c, 'web', 'peer-c', 'completed', now - 1 * day, null, 'done') + // D: needs_review 2d with a no-op action -> waiting_on_you, action nulled + worker(d, 'ops', 'peer-d', 'needs_review', now - 2 * day, 'none', 'needs_review') + + const o = overseer(buildEngine(store)) + const result = o.queryOpenLoops({}) + + // C excluded (closed by a later completed); A, D (waiting) before B (half-finished) + expect(result.openLoops.map((l) => l.sessionId)).toEqual([a, d, b]) + expect(result.counts).toEqual({ total: 3, waitingOnYou: 2, halfFinished: 1 }) + + const first = result.openLoops[0]! + expect(first.bucket).toBe('waiting_on_you') + expect(first.action).toBe('choose deploy target') + expect(first.ageDays).toBeGreaterThanOrEqual(9) + + // no-op action is nulled but the loop still surfaces + const second = result.openLoops[1]! + expect(second.sessionId).toBe(d) + expect(second.action).toBeNull() + expect(second.bucket).toBe('waiting_on_you') + + expect(result.openLoops[2]!.bucket).toBe('half_finished') + }) + + it('query_open_loops honors minAgeMs, project, and bucket filters', () => { + const store = new Store(':memory:') + const now = Date.now() + const day = 86_400_000 + const mkSession = (key: string, project: string) => + store.sessions.getOrCreateSession(key, { flavor: 'codex', path: `/tmp/${project}` }, null, 'default').id + const worker = (id: string, project: string, eventType: string, ts: number) => { + store.events.insert({ + ts, sourceKind: 'worker', eventType, attentionCandidate: 1, severity: 4, + summary: `${id}: ${eventType}`, relatedSessionId: id, + payloadJson: JSON.stringify({ notify_summary: { status: eventType, action: 'do a thing' }, session: { id, project } }) + }) + } + const a = mkSession('a', 'web') + const b = mkSession('b', 'api') + const d = mkSession('d', 'ops') + worker(a, 'web', 'needs_decision', now - 10 * day) + worker(b, 'api', 'blocked', now - 5 * day) + worker(d, 'ops', 'needs_review', now - 2 * day) + + const o = overseer(buildEngine(store)) + expect(o.queryOpenLoops({ minAgeMs: 4 * day }).openLoops.map((l) => l.sessionId)).toEqual([a, b]) + expect(o.queryOpenLoops({ project: 'web' }).openLoops.map((l) => l.sessionId)).toEqual([a]) + expect(o.queryOpenLoops({ bucket: 'half_finished' }).openLoops.map((l) => l.sessionId)).toEqual([b]) + expect(o.queryOpenLoops({ limit: 1 }).openLoops.length).toBe(1) + }) + it('recordConvoTurn writes a memory-bearing convo_turn event (never an inbox item)', () => { const { store, engine } = makeEngine() const inboxBefore = store.inbox.count() diff --git a/hub/src/sync/overseerEntity.ts b/hub/src/sync/overseerEntity.ts index 0d81774e40..6e734089f3 100644 --- a/hub/src/sync/overseerEntity.ts +++ b/hub/src/sync/overseerEntity.ts @@ -9,23 +9,29 @@ */ import { + OVERSEER_LOOP_CLOSED_EVENT_TYPE, OVERSEER_STALE_SILENCE_MS, buildOverseerConvoTurnEventInput, buildOverseerIdentity, buildOverseerSystemPrompt, deriveObservedWorkerState, inferWorkerState, + isNoOpAction, mapEventTypeToWorkerState, + openLoopBucket, type OverseerActiveWorker, type OverseerConvoTurnInput, type OverseerExplainPriority, type OverseerIdentity, + type OverseerOpenLoop, + type OverseerOpenLoopsResult, type OverseerRecentOutputChunk, type OverseerSessionStateView, type OverseerWorkerHealth, type OverseerWorkerState, type QueryEventsArgs, type QueryInboxArgs, + type QueryOpenLoopsArgs, type ListActiveWorkersArgs } from '@hapi/protocol' import { buildOverseerSessionIdentity } from '@hapi/protocol' @@ -45,6 +51,10 @@ export type OverseerEntityDeps = { staleSilenceMs?: number } +function isObjectRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function deriveIdentity(session: Session): { name: string | null; project: string | null; flavor: string | null } { const identity = buildOverseerSessionIdentity({ id: session.id, @@ -332,6 +342,80 @@ export class OverseerEntity { return roster.slice(0, limit) } + // --- Tool 8: query_open_loops ------------------------------------------- + + /** + * The "what am I forgetting?" lens (neglect axis, not urgency). For each + * session takes its latest status-bearing worker event; a loop is OPEN when + * that latest event is not `completed`. No-op `action` placeholders are + * nulled (status≠done is the strong filter; action is only a tiebreak). + * Presents "Waiting on You" (operator owes a decision) before half-finished + * work, each coldest-first. + */ + queryOpenLoops(args: QueryOpenLoopsArgs = {}): OverseerOpenLoopsResult { + const now = this.now() + const minAgeMs = args.minAgeMs ?? 0 + const limit = Math.min(Math.max(args.limit ?? 50, 1), 100) + + const loops: OverseerOpenLoop[] = [] + for (const event of this.events.latestWorkerStatusPerSession()) { + if (event.eventType === OVERSEER_LOOP_CLOSED_EVENT_TYPE) continue // loop closed by a later done turn + + const payload = this.parseEventPayload(event.payloadJson) + const notify = payload && isObjectRecord(payload.notify_summary) + ? payload.notify_summary as { status?: unknown; action?: unknown } + : null + const status = typeof notify?.status === 'string' && notify.status.length > 0 + ? notify.status + : event.eventType + const rawAction = typeof notify?.action === 'string' + ? notify.action + : typeof payload?.suggested_action === 'string' + ? payload.suggested_action + : null + const action = rawAction && !isNoOpAction(rawAction) ? rawAction.trim() : null + + const identity = this.openLoopIdentity(event) + if (args.project && identity.project !== args.project) continue + + const ageMs = Math.max(0, now - event.ts) + if (ageMs < minAgeMs) continue + + const bucket = openLoopBucket(event.eventType) + if (args.bucket && bucket !== args.bucket) continue + + loops.push({ + sessionId: event.relatedSessionId ?? '', + name: identity.name, + project: identity.project, + flavor: identity.flavor, + status, + eventType: event.eventType, + eventId: event.id, + action, + summary: event.summary, + lastTs: event.ts, + ageMs, + ageDays: Math.round((ageMs / 86_400_000) * 10) / 10, + bucket + }) + } + + // Waiting-on-You first, then half-finished; each coldest (oldest) first. + const bucketRank = (b: OverseerOpenLoop['bucket']): number => (b === 'waiting_on_you' ? 0 : 1) + loops.sort((a, b) => bucketRank(a.bucket) - bucketRank(b.bucket) || b.ageMs - a.ageMs) + + const waitingOnYou = loops.filter((l) => l.bucket === 'waiting_on_you').length + return { + openLoops: loops.slice(0, limit), + counts: { + total: loops.length, + waitingOnYou, + halfFinished: loops.length - waitingOnYou + } + } + } + // --- convo_turn writeback ----------------------------------------------- recordConvoTurn(input: OverseerConvoTurnInput): StoredSystemEvent | null { @@ -341,6 +425,36 @@ export class OverseerEntity { // --- internals ----------------------------------------------------------- + private parseEventPayload(payloadJson: string | null): { + notify_summary?: unknown + suggested_action?: unknown + session?: { id?: string; name?: string | null; project?: string | null; flavor?: string | null } + } | null { + if (!payloadJson) return null + try { + const parsed: unknown = JSON.parse(payloadJson) + return isObjectRecord(parsed) ? parsed : null + } catch { + return null + } + } + + /** Identity for an open loop — prefer the event's denormalized session (survives session deletion). */ + private openLoopIdentity(event: StoredSystemEvent): { name: string | null; project: string | null; flavor: string | null } { + const payload = this.parseEventPayload(event.payloadJson) + const denorm = payload?.session + if (denorm && (denorm.name || denorm.project || denorm.flavor)) { + return { + name: denorm.name ?? null, + project: denorm.project ?? null, + flavor: denorm.flavor ?? null + } + } + const session = event.relatedSessionId ? this.getSession(event.relatedSessionId) : undefined + if (session) return deriveIdentity(session) + return { name: null, project: null, flavor: null } + } + private computeLastActivityAt(session: Session, latestEvent: StoredSystemEvent | null): number | null { const candidates = [session.activeAt, session.updatedAt, latestEvent?.ts] .filter((value): value is number => typeof value === 'number' && value > 0) diff --git a/hub/src/web/routes/overseer.test.ts b/hub/src/web/routes/overseer.test.ts index 9972a896d6..2204bb6854 100644 --- a/hub/src/web/routes/overseer.test.ts +++ b/hub/src/web/routes/overseer.test.ts @@ -29,7 +29,7 @@ describe('overseer routes', () => { systemPrompt: string } expect(body.identity.canDispatch).toBe(false) - expect(body.identity.tools.length).toBe(7) + expect(body.identity.tools.length).toBe(8) expect(body.systemPrompt).toContain('Overseer') }) diff --git a/shared/src/overseerConverse.ts b/shared/src/overseerConverse.ts index 417de90127..8458a5ae63 100644 --- a/shared/src/overseerConverse.ts +++ b/shared/src/overseerConverse.ts @@ -136,6 +136,12 @@ const OVERSEER_TOOL_PARAMS: Record = { state: { type: 'string', enum: [...OVERSEER_WORKER_STATES] }, minAgeMs: { type: 'integer', minimum: 0 }, limit: { type: 'integer', minimum: 1, maximum: 200 } + }), + query_open_loops: obj({ + minAgeMs: { type: 'integer', minimum: 0, description: 'Only loops at least this old (ms). Raise it to focus on genuinely cold threads.' }, + bucket: { type: 'string', enum: ['waiting_on_you', 'half_finished'], description: 'Restrict to one bucket; omit for both (waiting_on_you first).' }, + project: { type: 'string' }, + limit: { type: 'integer', minimum: 1, maximum: 100 } }) } diff --git a/shared/src/overseerEntity.ts b/shared/src/overseerEntity.ts index 8043ab5506..eb252076fb 100644 --- a/shared/src/overseerEntity.ts +++ b/shared/src/overseerEntity.ts @@ -262,7 +262,8 @@ export const OVERSEER_TOOL_NAMES = [ 'get_session_recent_output', 'get_worker_health', 'explain_priority', - 'list_active_workers' + 'list_active_workers', + 'query_open_loops' ] as const export type OverseerToolName = typeof OVERSEER_TOOL_NAMES[number] @@ -320,6 +321,44 @@ export const listActiveWorkersArgsSchema = z.object({ }) export type ListActiveWorkersArgs = z.infer +export const queryOpenLoopsArgsSchema = z.object({ + /** Only loops at least this old (ms) — the "went cold" knob. Default 0. */ + minAgeMs: z.number().int().nonnegative().optional(), + /** Restrict to one lens bucket. Default: both, waiting_on_you first. */ + bucket: z.enum(['waiting_on_you', 'half_finished']).optional(), + project: z.string().min(1).optional(), + limit: z.number().int().min(1).max(100).optional() +}) +export type QueryOpenLoopsArgs = z.infer + +/** + * One cold open loop: a session whose latest worker status is not `done`, + * carrying how long it has sat and which lens bucket it belongs to. + */ +export type OverseerOpenLoop = { + sessionId: string + name: string | null + project: string | null + flavor: string | null + /** Worker notify status (needs_decision / blocked / failed / …). */ + status: string + /** Stored event_type of the latest open event. */ + eventType: string + eventId: number + /** Concrete next step, or null when the agent left a no-op placeholder. */ + action: string | null + summary: string + lastTs: number + ageMs: number + ageDays: number + bucket: 'waiting_on_you' | 'half_finished' +} + +export type OverseerOpenLoopsResult = { + openLoops: OverseerOpenLoop[] + counts: { total: number; waitingOnYou: number; halfFinished: number } +} + export const overseerToolArgsSchemas = { query_events: queryEventsArgsSchema, query_inbox: queryInboxArgsSchema, @@ -327,7 +366,8 @@ export const overseerToolArgsSchemas = { get_session_recent_output: getSessionRecentOutputArgsSchema, get_worker_health: getWorkerHealthArgsSchema, explain_priority: explainPriorityArgsSchema, - list_active_workers: listActiveWorkersArgsSchema + list_active_workers: listActiveWorkersArgsSchema, + query_open_loops: queryOpenLoopsArgsSchema } as const satisfies Record export type OverseerToolCatalogEntry = { @@ -372,6 +412,11 @@ export const OVERSEER_TOOL_CATALOG: OverseerToolCatalogEntry[] = [ name: 'list_active_workers', description: 'Summary roster of workers, filterable by project, state, and minimum age.', readonly: true + }, + { + name: 'query_open_loops', + description: 'The "what am I forgetting?" lens: threads whose latest worker status is NOT done (needs_decision / needs_review / blocked / failed / stalled) and never got closed, sorted coldest-first. "Waiting on You" (a decision the operator owes) is bucketed separately from half-finished work. Neglect-axis, not priority — use this for "what have I abandoned / forgotten?", not "what is most urgent?".', + readonly: true } ] @@ -422,6 +467,7 @@ export function buildOverseerSystemPrompt(): string { '- get_worker_health — reported + observed + inferred state for one worker.', '- explain_priority — why an inbox item sits where it does, with its provenance.', '- list_active_workers — the current roster, filterable by project / state / age.', + '- query_open_loops — the "what am I forgetting?" lens: cold threads whose latest status is not done.', '', 'You CANNOT dispatch, message workers, spawn, confirm, or change any state. If the operator asks', 'you to act on a worker, say plainly that you can advise but cannot dispatch yet, and tell them', @@ -439,6 +485,23 @@ export function buildOverseerSystemPrompt(): string { '- Prioritize. Surface the root cause, not five symptoms ("GitHub auth is blocking 5 workers",', ' not a roll-call of each blocked worker).', '', + '# Two questions, two axes', + '', + 'Urgency and neglect are different axes; do not conflate them.', + '', + '- "What needs me now?" / "what is most urgent?" → an URGENCY question. Use query_inbox (and', + ' explain_priority), which are priority-ordered.', + '- "What am I forgetting / abandoning / neglecting?" / "what have I not touched?" → a NEGLECT', + ' question. Use query_open_loops, which is age-sorted (coldest first) over threads whose latest', + ' status is not done. Lead with the "Waiting on You" bucket (a decision the operator owes), then', + ' half-finished work. A very old low-priority loop can matter here even if it is not urgent.', + '', + '# Priority direction (do not get this backwards)', + '', + 'Inbox priority is LOWER-IS-HIGHER: priority 1 is the MOST important, priority 90 is the LEAST.', + 'Never call a high number "highest priority". When you sort or rank by priority, the smallest', + 'number comes first. If you cite a number, say e.g. "priority 5 (near the top)".', + '', '# Voice output', '', '- Keep spoken answers to 1-3 sentences unless asked for depth.', diff --git a/shared/src/overseerEvents.test.ts b/shared/src/overseerEvents.test.ts index 94956fdd8d..38f0389e76 100644 --- a/shared/src/overseerEvents.test.ts +++ b/shared/src/overseerEvents.test.ts @@ -8,12 +8,15 @@ import { deriveSessionDisplayName, deriveSessionProject, extractHttpUrls, + isNoOpAction, mapNotifyStatusToEventType, buildEventSummaryFromNotify, detectEmptyHapiEventsSentinel, mergeEventPayloadWithSession, normalizeUrlIdempotencyKey, + openLoopBucket, OVERSEER_EVENT_TYPES, + OVERSEER_OPEN_LOOP_EVENT_TYPES, HAPI_EVENTS_BEGIN, HAPI_EVENTS_END } from './overseerEvents' @@ -112,4 +115,31 @@ describe('overseerEvents mapping', () => { test('normalizeUrlIdempotencyKey drops hash and lowercases host', () => { expect(normalizeUrlIdempotencyKey('https://Example.COM/path/#frag')).toBe('https://example.com/path') }) + + test('isNoOpAction treats placeholders and empties as no action', () => { + for (const noop of ['', ' ', 'none', 'None.', 'N/A', 'complete', 'Done', 'nothing', 'no action', 'optional', '-', '—', 'tbd']) { + expect(isNoOpAction(noop)).toBe(true) + } + expect(isNoOpAction(null)).toBe(true) + expect(isNoOpAction(undefined)).toBe(true) + }) + + test('isNoOpAction keeps a real next step', () => { + expect(isNoOpAction('Merge PR #99')).toBe(false) + expect(isNoOpAction('choose deploy target')).toBe(false) + }) + + test('openLoopBucket splits waiting-on-you from half-finished', () => { + expect(openLoopBucket('needs_decision')).toBe('waiting_on_you') + expect(openLoopBucket('needs_review')).toBe('waiting_on_you') + expect(openLoopBucket('blocked')).toBe('half_finished') + expect(openLoopBucket('failed')).toBe('half_finished') + expect(openLoopBucket('stale')).toBe('half_finished') + }) + + test('OVERSEER_OPEN_LOOP_EVENT_TYPES excludes completed and progress', () => { + expect(OVERSEER_OPEN_LOOP_EVENT_TYPES).not.toContain('completed') + expect(OVERSEER_OPEN_LOOP_EVENT_TYPES).not.toContain('progress') + expect(OVERSEER_OPEN_LOOP_EVENT_TYPES).toContain('needs_decision') + }) }) diff --git a/shared/src/overseerEvents.ts b/shared/src/overseerEvents.ts index b9f44a895f..6b9c1aae9b 100644 --- a/shared/src/overseerEvents.ts +++ b/shared/src/overseerEvents.ts @@ -57,6 +57,73 @@ export const OVERSEER_EVENT_TYPES = [ export type OverseerEventType = typeof OVERSEER_EVENT_TYPES[number] +/** + * Event types whose latest occurrence per session marks a still-open loop — a + * thread whose most recent worker status is NOT `done`. `completed` is included + * here (not as an open type) because it is what *closes* a loop: the cold-open- + * loops lens takes the latest event of this set per session and treats it as + * open only when that latest event is not `completed`. `progress` is excluded + * on purpose — a progress ping does not close an operator-owed decision. + */ +export const OVERSEER_OPEN_LOOP_EVENT_TYPES = [ + 'needs_decision', + 'needs_review', + 'blocked', + 'failed', + 'stale' +] as const + +export type OverseerOpenLoopEventType = typeof OVERSEER_OPEN_LOOP_EVENT_TYPES[number] + +/** The event type that closes an open loop (latest `done` turn). */ +export const OVERSEER_LOOP_CLOSED_EVENT_TYPE = 'completed' as const + +/** + * No-op `action` values agents stuff into a summary when nothing is actually + * pending ("none", "complete", "n/a", …). Treated as *no action* so the lens + * does not surface a done-shaped thread as a live loop. Matching is on the + * trimmed, lowercased, punctuation-stripped action. + */ +const NO_OP_ACTION_VALUES = new Set([ + '', + 'none', + 'n/a', + 'na', + 'nil', + 'nothing', + 'no action', + 'no further action', + 'no followup', + 'no follow-up', + 'no follow up', + 'no-op', + 'noop', + 'complete', + 'completed', + 'done', + 'finished', + 'optional', + 'tbd' +]) + +/** + * True when an `action` string is absent or a known no-op placeholder. The lens + * keeps a loop regardless (status≠done is the strong filter), but nulls a no-op + * action so it is not mistaken for a real next step (spec: "action is a tiebreak"). + */ +export function isNoOpAction(action: string | null | undefined): boolean { + if (action == null) return true + const normalized = action.trim().toLowerCase().replace(/[.!\-—–\s]+$/g, '').trim() + return normalized.length === 0 || NO_OP_ACTION_VALUES.has(normalized) +} + +/** Which lens bucket an open-loop event type belongs to. */ +export function openLoopBucket(eventType: string): 'waiting_on_you' | 'half_finished' { + return eventType === 'needs_decision' || eventType === 'needs_review' + ? 'waiting_on_you' + : 'half_finished' +} + export type OverseerArtifactRef = { kind: string url?: string From 6792bea07e62206fd8274ef55636e84eeb2735da Mon Sep 17 00:00:00 2001 From: HeavyGee <133152184+heavygee@users.noreply.github.com> Date: Fri, 31 Jul 2026 09:58:13 +0000 Subject: [PATCH 2/2] feat(overseer): detail knob + session-tool projection + item-backlog prompt (H1/H2/H4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ingest-peer handoff (PR #99 -> converse/entity layer): - H2: two-level `detail: 'lean'|'full'` (default lean) on every context tool, threaded into projectToolResultForBrain(tool, result, detail). Closes the coverage gap — get_session_state, get_session_recent_output (raw terminal text capped at 280 chars in lean; was a token bomb), and get_worker_health (signal trail dropped in lean) now have lean projections. `full` returns raw rows, still bounded by limit/n + the outer char clamp. No token-budget engine. - H1: system-prompt rule — for a specific inbox item, pull explain_priority then query_events{sessionId} for that session's backlog as salience before answering. - H4: confirmed query_open_loops spans all non-deleted sessions (active AND archived); never filters on session.active. Documented in the method + doc. - H3: query_session_actions reader deferred (0 dispositions today). Tests: session-state/recent-output/worker-health lean projections, detail:full passthrough; pass-through test moved to explain_priority. Co-authored-by: Cursor --- ...7-31-overseer-open-loops-implementation.md | 19 +++++ hub/src/overseer/converse.ts | 6 +- hub/src/overseer/toolProjection.test.ts | 36 +++++++++- hub/src/overseer/toolProjection.ts | 71 ++++++++++++++++++- hub/src/sync/overseerEntity.ts | 5 ++ shared/src/overseerConverse.ts | 25 +++++-- shared/src/overseerEntity.ts | 33 +++++++-- 7 files changed, 175 insertions(+), 20 deletions(-) diff --git a/docs/plans/2026-07-31-overseer-open-loops-implementation.md b/docs/plans/2026-07-31-overseer-open-loops-implementation.md index 3de8194121..8b312f4de6 100644 --- a/docs/plans/2026-07-31-overseer-open-loops-implementation.md +++ b/docs/plans/2026-07-31-overseer-open-loops-implementation.md @@ -55,6 +55,25 @@ priority; this is about neglect, not urgency. Keep it to the top ~15. This doubles as a **triage bootstrap**: surface ~15 cold loops, operator dispositions them, and the inbox disposition loop the spec always wanted becomes tractable. +## Ingest-peer handoff items (PR #99 → this layer) + +- **H1 (done):** system-prompt rule — when the operator asks about a *specific* inbox item, the brain + first calls `explain_priority` then `query_events{sessionId}` to pull the rest of that session's + recorded activity as salience (capability already existed; `sessionId` is an accepted arg). +- **H2 (done):** a two-level `detail: 'lean' | 'full'` knob (default `lean`) on every context tool + (`query_events` / `query_inbox` / `get_session_state` / `get_session_recent_output` / + `get_worker_health` / `list_active_workers` / `query_open_loops`), threaded into + `projectToolResultForBrain(tool, result, detail)`. Coverage gap closed: `get_session_state`, + `get_session_recent_output` (raw terminal text capped at 280 chars in lean — was a token bomb), and + `get_worker_health` (signal trail dropped in lean) now have lean projections. `full` returns the raw + rows, still bounded by `limit`/`n` and the outer char clamp. Deliberately NOT a token-budget engine — + two levels + good defaults. +- **H3 (deferred):** `query_session_actions` reader over `inbox_operator_actions` — deferred until + disposition volume justifies it (~0 today). ~30-line add when wanted. +- **H4 (confirmed):** `query_open_loops` spans **all non-deleted sessions** (active AND archived). It + reads only the events table and never filters on `session.active`; deleted sessions drop out because + `deleteSession` detaches their events (`related_session_id = NULL`). + ## Not in this branch (follow-ups) - **Archiving hygiene (step 3):** aggressive session archive + sweeping legacy `stale` diff --git a/hub/src/overseer/converse.ts b/hub/src/overseer/converse.ts index fafd58e493..9665b86876 100644 --- a/hub/src/overseer/converse.ts +++ b/hub/src/overseer/converse.ts @@ -124,8 +124,10 @@ export async function runOverseerConverse(params: { try { const result = runOverseerTool(overseer, name, args) toolTrace.push({ tool: name, args, ok: true }) - const lean = projectToolResultForBrain(name, result) - resultLines.push(`${name}(${argsRaw}) => ${clampToolResult(JSON.stringify(lean ?? null))}`) + // The brain opts into 'full' per call when it needs depth; default lean. + const detail = args.detail === 'full' ? 'full' : 'lean' + const projected = projectToolResultForBrain(name, result, detail) + resultLines.push(`${name}(${argsRaw}) => ${clampToolResult(JSON.stringify(projected ?? null))}`) } catch (error) { const msg = error instanceof Error ? error.message : String(error) toolTrace.push({ tool: name, args, ok: false, error: msg }) diff --git a/hub/src/overseer/toolProjection.test.ts b/hub/src/overseer/toolProjection.test.ts index 60045ddf41..af71cddad6 100644 --- a/hub/src/overseer/toolProjection.test.ts +++ b/hub/src/overseer/toolProjection.test.ts @@ -81,8 +81,38 @@ describe('projectToolResultForBrain', () => { expect(JSON.stringify(lean)).not.toContain('lastTs') }) - it('passes un-projected tools through untouched', () => { - const state = { state: { sessionId: 'x', observedState: 'idle' } } - expect(projectToolResultForBrain('get_session_state', state)).toBe(state) + it('thins session state to observed/reported essentials', () => { + const raw = { state: { sessionId: 'sess-a', name: 'peer-a', project: 'web', flavor: 'codex', active: true, thinking: false, observedState: 'idle', workerReportedState: 'blocked', lastActivityAt: 999, silenceMs: 1200, lastToolCallAgeMs: 500, pendingRequestCount: 1 } } + const lean = projectToolResultForBrain('get_session_state', raw) as { state: Record } + expect(lean.state).toEqual({ id: 'sess-a', name: 'peer-a', project: 'web', observed: 'idle', reported: 'blocked', silenceMs: 1200, pending: 1 }) + expect(JSON.stringify(lean)).not.toContain('lastToolCallAgeMs') + }) + + it('caps raw transcript chunk text (token bomb) in lean mode', () => { + const long = 'x'.repeat(1000) + const raw = { chunks: [{ messageId: 'm1', role: 'worker', text: long, createdAt: 5 }] } + const lean = projectToolResultForBrain('get_session_recent_output', raw) as { total: number; chunks: Array<{ text: string }> } + expect(lean.total).toBe(1) + expect(lean.chunks[0]!.text.length).toBeLessThan(300) + expect(lean.chunks[0]!.text.endsWith('…')).toBe(true) + }) + + it('thins worker health and drops the verbose signal trail', () => { + const raw = { health: { sessionId: 'sess-a', name: 'peer-a', project: 'web', flavor: 'codex', reportedState: 'blocked', observedState: 'stale', inferredState: 'blocked', inferredConfidence: 0.9, signals: ['a', 'b', 'c'], lastActivityAt: 1, silenceMs: 60000, pendingRequestCount: 0 } } + const lean = projectToolResultForBrain('get_worker_health', raw) as { health: Record } + expect(lean.health).toEqual({ id: 'sess-a', name: 'peer-a', project: 'web', reported: 'blocked', observed: 'stale', inferred: 'blocked', confidence: 0.9, silenceMs: 60000, pending: 0 }) + expect(JSON.stringify(lean)).not.toContain('signals') + }) + + it('detail:full returns the raw rows untouched', () => { + const raw = { chunks: [{ messageId: 'm1', role: 'worker', text: 'x'.repeat(1000), createdAt: 5 }] } + expect(projectToolResultForBrain('get_session_recent_output', raw, 'full')).toBe(raw) + const inbox = { items: [{ id: 1, title: 't', status: 'new', priority: 5, reasonForPriority: 'r' }] } + expect(projectToolResultForBrain('query_inbox', inbox, 'full')).toBe(inbox) + }) + + it('passes un-projected tools (explain_priority) through untouched', () => { + const explanation = { explanation: { inboxItemId: 1, title: 'x' } } + expect(projectToolResultForBrain('explain_priority', explanation)).toBe(explanation) }) }) diff --git a/hub/src/overseer/toolProjection.ts b/hub/src/overseer/toolProjection.ts index 5c61ff15be..33be657c03 100644 --- a/hub/src/overseer/toolProjection.ts +++ b/hub/src/overseer/toolProjection.ts @@ -11,12 +11,28 @@ import type { OverseerToolName } from '@hapi/protocol' * * Projection is applied ONLY on the converse path (brain-facing). The HTTP tool * endpoint and debug panels still get the full rows. + * + * The `detail` knob is a deliberately TWO-LEVEL switch (no token-budget engine): + * - `lean` (default) — the cheap shapes below (~20x smaller). + * - `full` — the raw richer rows, still bounded by the tool's `limit`/`n` arg and + * by the outer MAX_TOOL_RESULT_CHARS clamp in the converse loop. + * The brain opts into `full` per call when it genuinely needs depth. */ +export type ToolResultDetail = 'lean' | 'full' + +/** Cap raw transcript chunk text in lean mode (raw terminal output is a token bomb). */ +const LEAN_CHUNK_TEXT_MAX = 280 + function isObj(value: unknown): value is Record { return typeof value === 'object' && value !== null } +function truncate(text: unknown, max: number): unknown { + if (typeof text !== 'string') return text + return text.length > max ? `${text.slice(0, max)}…` : text +} + /** * Inbox item → the minimum for triage: * - `id` — to reference it (explain_priority, follow-ups) @@ -64,6 +80,42 @@ function projectWorker(worker: unknown): Record { } } +/** Session state → the observed/reported essentials; drops raw activity timestamps. */ +function projectSessionState(state: unknown): Record { + const o = isObj(state) ? state : {} + return { + id: o.sessionId, + name: o.name, + project: o.project, + observed: o.observedState, + reported: o.workerReportedState, + silenceMs: o.silenceMs, + pending: o.pendingRequestCount + } +} + +/** Transcript chunk → role + capped text (raw terminal output would blow the window). */ +function projectChunk(chunk: unknown): Record { + const o = isObj(chunk) ? chunk : {} + return { role: o.role, at: o.createdAt, text: truncate(o.text, LEAN_CHUNK_TEXT_MAX) } +} + +/** Worker health → the three states + confidence; drops the verbose signal trail (full only). */ +function projectWorkerHealth(health: unknown): Record { + const o = isObj(health) ? health : {} + return { + id: o.sessionId, + name: o.name, + project: o.project, + reported: o.reportedState, + observed: o.observedState, + inferred: o.inferredState, + confidence: o.inferredConfidence, + silenceMs: o.silenceMs, + pending: o.pendingRequestCount + } +} + /** Open loop → the minimum for the "what am I forgetting?" answer. */ function projectOpenLoop(loop: unknown): Record { const o = isObj(loop) ? loop : {} @@ -79,7 +131,15 @@ function projectOpenLoop(loop: unknown): Record { } } -export function projectToolResultForBrain(tool: OverseerToolName, result: unknown): unknown { +export function projectToolResultForBrain( + tool: OverseerToolName, + result: unknown, + detail: ToolResultDetail = 'lean' +): unknown { + // `full` returns the raw rows — still bounded by the tool's limit/n arg and + // the converse loop's outer char clamp. No thinning applied. + if (detail === 'full') return result + if (tool === 'query_events' && isObj(result) && Array.isArray(result.events)) { return { total: result.events.length, events: result.events.map(projectEvent) } } @@ -89,6 +149,15 @@ export function projectToolResultForBrain(tool: OverseerToolName, result: unknow if (tool === 'query_open_loops' && isObj(result) && Array.isArray(result.openLoops)) { return { counts: result.counts, openLoops: result.openLoops.map(projectOpenLoop) } } + if (tool === 'get_session_state' && isObj(result) && 'state' in result) { + return { state: result.state == null ? null : projectSessionState(result.state) } + } + if (tool === 'get_session_recent_output' && isObj(result) && Array.isArray(result.chunks)) { + return { total: result.chunks.length, chunks: result.chunks.map(projectChunk) } + } + if (tool === 'get_worker_health' && isObj(result) && 'health' in result) { + return { health: result.health == null ? null : projectWorkerHealth(result.health) } + } if (tool === 'query_inbox' && isObj(result) && Array.isArray(result.items)) { // The raw result is {items, candidates, surfaced, held} — four arrays that // repeat the same rows (a big part of the ~75k-token bloat). We keep only diff --git a/hub/src/sync/overseerEntity.ts b/hub/src/sync/overseerEntity.ts index 6e734089f3..232d105482 100644 --- a/hub/src/sync/overseerEntity.ts +++ b/hub/src/sync/overseerEntity.ts @@ -351,6 +351,11 @@ export class OverseerEntity { * nulled (status≠done is the strong filter; action is only a tiebreak). * Presents "Waiting on You" (operator owes a decision) before half-finished * work, each coldest-first. + * + * Spans ALL non-deleted sessions (active AND archived) — it reads only the + * events table and never filters on `session.active`. Deleted sessions drop + * out automatically because `deleteSession` detaches their events + * (`related_session_id = NULL`) and the substrate query requires it non-null. */ queryOpenLoops(args: QueryOpenLoopsArgs = {}): OverseerOpenLoopsResult { const now = this.now() diff --git a/shared/src/overseerConverse.ts b/shared/src/overseerConverse.ts index 8458a5ae63..40ffeed9f8 100644 --- a/shared/src/overseerConverse.ts +++ b/shared/src/overseerConverse.ts @@ -102,6 +102,12 @@ function obj(properties: Record, required: string[] = []): J const sessionIdProp: JsonSchema = { type: 'string', description: 'Exact session id (resolve a human name via list_active_workers first).' } +const detailProp: JsonSchema = { + type: 'string', + enum: ['lean', 'full'], + description: 'Output detail. Default "lean" (cheap summary). Ask "full" only when you need the richer rows (e.g. the worker-health signal trail or untruncated transcript text); still bounded by limit.' +} + /** Hand-mapped params mirroring `overseerToolArgsSchemas` (kept simple + stable). */ const OVERSEER_TOOL_PARAMS: Record = { query_events: obj({ @@ -114,20 +120,23 @@ const OVERSEER_TOOL_PARAMS: Record = { sinceTs: { type: 'integer', minimum: 0, description: 'Epoch ms lower bound.' }, untilTs: { type: 'integer', minimum: 0, description: 'Epoch ms upper bound.' }, beforeId: { type: 'integer', minimum: 1 }, - limit: { type: 'integer', minimum: 1, maximum: 200 } + limit: { type: 'integer', minimum: 1, maximum: 200 }, + detail: detailProp }), query_inbox: obj({ statuses: { type: 'array', items: { type: 'string' }, description: 'e.g. candidate, surfaced, held.' }, sessionId: sessionIdProp, category: { type: 'string' }, - limit: { type: 'integer', minimum: 1, maximum: 200 } + limit: { type: 'integer', minimum: 1, maximum: 200 }, + detail: detailProp }), - get_session_state: obj({ sessionId: sessionIdProp }, ['sessionId']), + get_session_state: obj({ sessionId: sessionIdProp, detail: detailProp }, ['sessionId']), get_session_recent_output: obj({ sessionId: sessionIdProp, - n: { type: 'integer', minimum: 1, maximum: 50, description: 'How many recent transcript chunks.' } + n: { type: 'integer', minimum: 1, maximum: 50, description: 'How many recent transcript chunks.' }, + detail: detailProp }, ['sessionId']), - get_worker_health: obj({ sessionId: sessionIdProp }, ['sessionId']), + get_worker_health: obj({ sessionId: sessionIdProp, detail: detailProp }, ['sessionId']), explain_priority: obj({ itemId: { type: 'integer', minimum: 1, description: 'Inbox item id.' } }, ['itemId']), @@ -135,13 +144,15 @@ const OVERSEER_TOOL_PARAMS: Record = { project: { type: 'string' }, state: { type: 'string', enum: [...OVERSEER_WORKER_STATES] }, minAgeMs: { type: 'integer', minimum: 0 }, - limit: { type: 'integer', minimum: 1, maximum: 200 } + limit: { type: 'integer', minimum: 1, maximum: 200 }, + detail: detailProp }), query_open_loops: obj({ minAgeMs: { type: 'integer', minimum: 0, description: 'Only loops at least this old (ms). Raise it to focus on genuinely cold threads.' }, bucket: { type: 'string', enum: ['waiting_on_you', 'half_finished'], description: 'Restrict to one bucket; omit for both (waiting_on_you first).' }, project: { type: 'string' }, - limit: { type: 'integer', minimum: 1, maximum: 100 } + limit: { type: 'integer', minimum: 1, maximum: 100 }, + detail: detailProp }) } diff --git a/shared/src/overseerEntity.ts b/shared/src/overseerEntity.ts index eb252076fb..071c576d6c 100644 --- a/shared/src/overseerEntity.ts +++ b/shared/src/overseerEntity.ts @@ -270,6 +270,15 @@ export type OverseerToolName = typeof OVERSEER_TOOL_NAMES[number] const sessionIdSchema = z.string().min(1) +/** + * Output-detail knob shared by every context-gathering tool. `lean` (default) + * returns the cheap brain-facing shape; `full` returns the richer rows (still + * bounded by `limit`/`n`). Two levels only — no token-budget engine. The knob is + * consumed by the converse projection layer; the entity methods ignore it. + */ +export const toolDetailSchema = z.enum(['lean', 'full']) +export type ToolResultDetailArg = z.infer + export const queryEventsArgsSchema = z.object({ sessionId: sessionIdSchema.optional(), project: z.string().min(1).optional(), @@ -280,7 +289,8 @@ export const queryEventsArgsSchema = z.object({ sinceTs: z.number().int().nonnegative().optional(), untilTs: z.number().int().nonnegative().optional(), beforeId: z.number().int().positive().optional(), - limit: z.number().int().min(1).max(200).optional() + limit: z.number().int().min(1).max(200).optional(), + detail: toolDetailSchema.optional() }) export type QueryEventsArgs = z.infer @@ -288,23 +298,27 @@ export const queryInboxArgsSchema = z.object({ statuses: z.array(z.string().min(1)).min(1).optional(), sessionId: sessionIdSchema.optional(), category: z.string().min(1).optional(), - limit: z.number().int().min(1).max(200).optional() + limit: z.number().int().min(1).max(200).optional(), + detail: toolDetailSchema.optional() }) export type QueryInboxArgs = z.infer export const getSessionStateArgsSchema = z.object({ - sessionId: sessionIdSchema + sessionId: sessionIdSchema, + detail: toolDetailSchema.optional() }) export type GetSessionStateArgs = z.infer export const getSessionRecentOutputArgsSchema = z.object({ sessionId: sessionIdSchema, - n: z.number().int().min(1).max(50).optional() + n: z.number().int().min(1).max(50).optional(), + detail: toolDetailSchema.optional() }) export type GetSessionRecentOutputArgs = z.infer export const getWorkerHealthArgsSchema = z.object({ - sessionId: sessionIdSchema + sessionId: sessionIdSchema, + detail: toolDetailSchema.optional() }) export type GetWorkerHealthArgs = z.infer @@ -317,7 +331,8 @@ export const listActiveWorkersArgsSchema = z.object({ project: z.string().min(1).optional(), state: z.enum(OVERSEER_WORKER_STATES).optional(), minAgeMs: z.number().int().nonnegative().optional(), - limit: z.number().int().min(1).max(200).optional() + limit: z.number().int().min(1).max(200).optional(), + detail: toolDetailSchema.optional() }) export type ListActiveWorkersArgs = z.infer @@ -327,7 +342,8 @@ export const queryOpenLoopsArgsSchema = z.object({ /** Restrict to one lens bucket. Default: both, waiting_on_you first. */ bucket: z.enum(['waiting_on_you', 'half_finished']).optional(), project: z.string().min(1).optional(), - limit: z.number().int().min(1).max(100).optional() + limit: z.number().int().min(1).max(100).optional(), + detail: toolDetailSchema.optional() }) export type QueryOpenLoopsArgs = z.infer @@ -484,6 +500,9 @@ export function buildOverseerSystemPrompt(): string { '- Prefer direct tool/system evidence over a worker\'s self-report when they conflict.', '- Prioritize. Surface the root cause, not five symptoms ("GitHub auth is blocking 5 workers",', ' not a roll-call of each blocked worker).', + '- When the operator asks about a SPECIFIC inbox item, first call explain_priority for its', + ' provenance, then query_events with that item\'s sessionId to pull the rest of that session\'s', + ' recorded activity as context/salience before answering — do not answer from the item alone.', '', '# Two questions, two axes', '',