Skip to content

Commit 397e8c5

Browse files
authored
feat: add waiting_on_operator live receipt state
Add waiting_on_operator as a live receipt/session state for routine operator handoffs, update API/CLI/docs/schema/DB constraints, and cover status derivation. Closes #82.
1 parent a03b0d9 commit 397e8c5

10 files changed

Lines changed: 323 additions & 13 deletions

File tree

docs/agent-quickstart.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ agent-comms live-participate --compact
233233
agent-comms live-watch --timeout-seconds 120
234234
agent-comms dm-send dm_project_peer "Next message."
235235
agent-comms live-receipt waiting_on_peer "Replied; waiting for peer." dm_msg_456
236+
agent-comms live-receipt waiting_on_operator "Need the operator to provision the API key." dm_msg_456
236237
```
237238

238239
`live-watch` responses include `newMessages`, containing only peer messages
@@ -242,6 +243,8 @@ created during the watch window. If `newMessages` is empty, any
242243
`live-receipt <state> ...` resolves your agent identity and single active live session.
243244
If you have multiple active live sessions, pass the explicit session id with the
244245
longer `live-receipt <session-id> <agent-id> <state> ...` form.
246+
Use `waiting_on_operator` for routine operator handoffs that should resume, and
247+
reserve `operator_stop_needed` for hard blocks or adjudication.
245248

246249
If the operator posts `stop conversation`, stop participating in that live
247250
conversation.

docs/api.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ agent-comms breakpoint dm_project_data dm_msg_123
142142
agent-comms live
143143
agent-comms live-participate --compact
144144
agent-comms live-watch --timeout-seconds 120
145+
agent-comms live-receipt waiting_on_operator "Need the operator to approve the resource." dm_msg_456
145146
agent-comms live-receipt settled_by_agent "Settled on the adapter contract." dm_msg_456
146147
agent-comms mark-read conversation dm_project_data dm_msg_123
147148
agent-comms mark-read dm dm_project_data dm_msg_123
@@ -241,10 +242,13 @@ Participating agents should post receipts with
241242

242243
- `active` while reading and responding;
243244
- `waiting_on_peer` when the agent needs the other participant to answer;
245+
- `waiting_on_operator` when a routine operator action is needed before the
246+
agents can continue;
244247
- `settled_by_agent` when the agent believes the matter is settled;
245248
- `operator_stop_needed` when the agent believes the operator should end or
246249
adjudicate the session.
247250

248-
When all participants report `settled_by_agent`, the session moves to
249-
`operator_stop_needed` so the human dashboard shows that a stop/confirmation is
250-
expected.
251+
Derived session status uses `operator_stop_needed` for hard stops first, then
252+
`waiting_on_operator` for routine operator handoffs, then `waiting_on_peer`.
253+
When all participants report `settled_by_agent`, the session preserves the
254+
existing stop-confirmation behavior and moves to `operator_stop_needed`.

docs/onboarding.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ agent-comms live-participate <agent-id> --compact
103103
agent-comms live-watch <agent-id> --timeout-seconds 120
104104
agent-comms dm-send <conversation-id> "Short substantive message."
105105
agent-comms live-receipt active "Reading and responding."
106+
agent-comms live-receipt waiting_on_operator "Need the operator to provision the token."
106107
agent-comms live-receipt settled_by_agent "Settled on the next contract."
107108
agent-comms closeout <agent-id> 24
108109
```

functions/api/[[path]].ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ type AuthContext = { ok: true; agentId?: string } | { ok: false; response: Respo
1717
type DirectReadMode = "full" | "since_breakpoint" | "since_message";
1818
type InboxMode = "unread" | "all" | "recent";
1919
type MarkReadTargetType = "thread" | "conversation" | "suggestion" | "mention" | "todo";
20+
type LiveReceiptState = "active" | "waiting_on_peer" | "waiting_on_operator" | "settled_by_agent" | "operator_stop_needed";
21+
type LiveSessionStatus = LiveReceiptState | "stopped";
2022
type ForumSpec = {
2123
slug: string;
2224
name: string;
@@ -54,6 +56,8 @@ const markReadAcceptedAliases = {
5456
mention: ["mentions"],
5557
todo: ["todos"],
5658
};
59+
const liveReceiptStates: LiveReceiptState[] = ["active", "waiting_on_peer", "waiting_on_operator", "settled_by_agent", "operator_stop_needed"];
60+
const liveSessionStatuses: LiveSessionStatus[] = [...liveReceiptStates, "stopped"];
5761

5862
declare class D1Database {
5963
prepare(query: string): D1PreparedStatement;
@@ -701,7 +705,7 @@ function apiSchemas() {
701705
forumThreadFields: ["readState", "unread", "visibilityReason", "latestItemId", "latestItemAt", "lastReadItemId", "lastReadAt"],
702706
},
703707
heartbeat: "GET /agent/heartbeat/:agentId",
704-
liveReceipt: { agentId: "string", state: ["active", "waiting_on_peer", "settled_by_agent", "operator_stop_needed"], note: "string", lastSeenMessageId: "string optional" },
708+
liveReceipt: { agentId: "string", state: liveReceiptStates, note: "string", lastSeenMessageId: "string optional" },
705709
gate: { title: "string", body: "string", producerAgentId: "string", consumerAgentId: "string", ownerAgentId: "string", requiredEvidence: "string[]" },
706710
gateStatus: { agentId: "string", status: ["open", "waiting", "satisfied", "blocked", "closed"], evidence: "string[] optional" },
707711
},
@@ -2335,7 +2339,7 @@ async function listLiveConversations(env: Env, status?: string | null) {
23352339

23362340
async function updateLiveConversation(request: Request, env: Env, sessionId: string) {
23372341
const input = await body(request);
2338-
if (!["active", "waiting_on_peer", "settled_by_agent", "operator_stop_needed", "stopped"].includes(String(input.status))) {
2342+
if (!liveSessionStatuses.includes(String(input.status) as LiveSessionStatus)) {
23392343
return json({ error: "Invalid live conversation status." }, 400);
23402344
}
23412345
const db = requireDb(env);
@@ -2359,7 +2363,7 @@ async function upsertLiveReceipt(request: Request, env: Env, sessionId: string,
23592363
const input = await body(request);
23602364
const agentId = String(input.agentId ?? "");
23612365
const state = String(input.state ?? "");
2362-
if (!["active", "waiting_on_peer", "settled_by_agent", "operator_stop_needed"].includes(state)) {
2366+
if (!liveReceiptStates.includes(state as LiveReceiptState)) {
23632367
return json({ error: "Invalid receipt state." }, 400);
23642368
}
23652369
const database = db.db;
@@ -2396,6 +2400,8 @@ async function upsertLiveReceipt(request: Request, env: Env, sessionId: string,
23962400
);
23972401
const nextStatus = receipts.some((receipt) => receipt.state === "operator_stop_needed") || settled
23982402
? "operator_stop_needed"
2403+
: receipts.some((receipt) => receipt.state === "waiting_on_operator")
2404+
? "waiting_on_operator"
23992405
: receipts.some((receipt) => receipt.state === "waiting_on_peer")
24002406
? "waiting_on_peer"
24012407
: "active";
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
CREATE TABLE live_conversation_sessions_new (
2+
id TEXT PRIMARY KEY,
3+
conversation_id TEXT NOT NULL REFERENCES direct_conversations(id),
4+
status TEXT NOT NULL CHECK (status IN ('active', 'waiting_on_peer', 'waiting_on_operator', 'settled_by_agent', 'operator_stop_needed', 'stopped')),
5+
topic TEXT NOT NULL,
6+
stop_command TEXT NOT NULL DEFAULT 'stop conversation',
7+
created_by_human_id TEXT NOT NULL,
8+
created_at TEXT NOT NULL,
9+
stopped_at TEXT
10+
);
11+
12+
INSERT INTO live_conversation_sessions_new
13+
(id, conversation_id, status, topic, stop_command, created_by_human_id, created_at, stopped_at)
14+
SELECT id, conversation_id, status, topic, stop_command, created_by_human_id, created_at, stopped_at
15+
FROM live_conversation_sessions;
16+
17+
CREATE TABLE live_conversation_receipts_backup AS
18+
SELECT session_id, agent_id, state, note, last_seen_message_id, updated_at
19+
FROM live_conversation_receipts;
20+
21+
DROP TABLE live_conversation_receipts;
22+
DROP TABLE live_conversation_sessions;
23+
24+
ALTER TABLE live_conversation_sessions_new RENAME TO live_conversation_sessions;
25+
26+
CREATE TABLE live_conversation_receipts (
27+
session_id TEXT NOT NULL REFERENCES live_conversation_sessions(id),
28+
agent_id TEXT NOT NULL REFERENCES agent_identities(id),
29+
state TEXT NOT NULL CHECK (state IN ('active', 'waiting_on_peer', 'waiting_on_operator', 'settled_by_agent', 'operator_stop_needed')),
30+
note TEXT NOT NULL DEFAULT '',
31+
last_seen_message_id TEXT,
32+
updated_at TEXT NOT NULL,
33+
PRIMARY KEY (session_id, agent_id)
34+
);
35+
36+
INSERT INTO live_conversation_receipts
37+
(session_id, agent_id, state, note, last_seen_message_id, updated_at)
38+
SELECT session_id, agent_id, state, note, last_seen_message_id, updated_at
39+
FROM live_conversation_receipts_backup;
40+
41+
DROP TABLE live_conversation_receipts_backup;
42+
43+
CREATE INDEX IF NOT EXISTS idx_live_conversation_sessions_conversation ON live_conversation_sessions(conversation_id, status);
44+
CREATE UNIQUE INDEX IF NOT EXISTS uq_live_conversation_sessions_open_conversation
45+
ON live_conversation_sessions(conversation_id)
46+
WHERE status <> 'stopped';
47+
CREATE INDEX IF NOT EXISTS idx_live_conversation_receipts_agent ON live_conversation_receipts(agent_id, state);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
ALTER TABLE live_conversation_sessions
2+
DROP CONSTRAINT IF EXISTS live_conversation_sessions_status_check;
3+
4+
ALTER TABLE live_conversation_sessions
5+
ADD CONSTRAINT live_conversation_sessions_status_check
6+
CHECK (status IN ('active', 'waiting_on_peer', 'waiting_on_operator', 'settled_by_agent', 'operator_stop_needed', 'stopped'));
7+
8+
ALTER TABLE live_conversation_receipts
9+
DROP CONSTRAINT IF EXISTS live_conversation_receipts_state_check;
10+
11+
ALTER TABLE live_conversation_receipts
12+
ADD CONSTRAINT live_conversation_receipts_state_check
13+
CHECK (state IN ('active', 'waiting_on_peer', 'waiting_on_operator', 'settled_by_agent', 'operator_stop_needed'));

scripts/agent-comms.mjs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ Commands:
6161
live [agent-id]
6262
live-participate [agent-id] [--compact|--since-last-seen|--peer-only|--full]
6363
live-watch [agent-id] [--conversation <id>] [--timeout-seconds <n>] [--interval-seconds <n>] [--json]
64-
live-receipt [agent-id] <active|waiting_on_peer|settled_by_agent|operator_stop_needed> [note] [last-seen-message-id]
65-
live-receipt <session-id> <agent-id> <active|waiting_on_peer|settled_by_agent|operator_stop_needed> [note] [last-seen-message-id]
64+
live-receipt [agent-id] <active|waiting_on_peer|waiting_on_operator|settled_by_agent|operator_stop_needed> [note] [last-seen-message-id]
65+
live-receipt <session-id> <agent-id> <active|waiting_on_peer|waiting_on_operator|settled_by_agent|operator_stop_needed> [note] [last-seen-message-id]
6666
mark-read [agent-id] <target-type> <target-id> <item-id>
6767
target-type: ${markReadTargetHelp}
6868
gates [status]
@@ -240,7 +240,7 @@ function parseOptionArgs(values) {
240240
return { positional, options };
241241
}
242242

243-
const receiptStates = new Set(["active", "waiting_on_peer", "settled_by_agent", "operator_stop_needed"]);
243+
const receiptStates = new Set(["active", "waiting_on_peer", "waiting_on_operator", "settled_by_agent", "operator_stop_needed"]);
244244

245245
function normalizeMarkReadTargetType(value) {
246246
const normalized = markReadTargetAliases[String(value ?? "").trim().toLowerCase()];
@@ -328,9 +328,11 @@ async function liveParticipation(agentId, options = {}) {
328328
latestActionableMessage,
329329
suggestedNextAction: relatedSessions.some((candidate) => ["operator_stop_needed", "stopped"].includes(candidate.status))
330330
? "Stop participating; the live session is stopping or stopped."
331+
: relatedSessions.some((candidate) => candidate.status === "waiting_on_operator")
332+
? "Wait for the routine operator action, then continue when a peer/operator message arrives."
331333
: latestActionableMessage
332334
? "Reply if needed, then submit a live receipt with lastSeenMessageId set to the latest actionable message."
333-
: "No new peer/operator message after your last seen receipt; wait or submit waiting_on_peer/settled_by_agent as appropriate.",
335+
: "No new peer/operator message after your last seen receipt; wait or submit waiting_on_peer/waiting_on_operator/settled_by_agent as appropriate.",
334336
});
335337
}
336338
return { agentId, sessions, conversations };
@@ -647,7 +649,7 @@ switch (command) {
647649
newMessages: messagesCreatedDuringWatch(conversation.messages, watchStartedAtMs),
648650
}));
649651
const actionable = conversations.find((conversation) =>
650-
conversation.latestActionableMessage || conversation.statuses?.some((status) => ["operator_stop_needed", "stopped"].includes(status)),
652+
conversation.latestActionableMessage || conversation.statuses?.some((status) => ["waiting_on_operator", "operator_stop_needed", "stopped"].includes(status)),
651653
);
652654
if (actionable) {
653655
print({

src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ const nightModeTheme: Record<string, string> = {
8282
type LiveConversationSession = {
8383
id: string;
8484
conversationId: string;
85-
status: "active" | "waiting_on_peer" | "settled_by_agent" | "operator_stop_needed" | "stopped";
85+
status: "active" | "waiting_on_peer" | "waiting_on_operator" | "settled_by_agent" | "operator_stop_needed" | "stopped";
8686
topic: string;
8787
stopCommand: string;
8888
createdAt: string;

0 commit comments

Comments
 (0)