omp-onlyne 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -8
- package/package.json +3 -3
- package/src/bridge-text.ts +40 -8
- package/src/config.ts +39 -5
- package/src/decider-text.ts +36 -21
- package/src/decider.ts +253 -548
- package/src/drafts.ts +68 -19
- package/src/guard.ts +107 -0
- package/src/handshake.ts +291 -0
- package/src/messages.ts +134 -16
- package/src/plan.ts +133 -0
- package/src/questions.ts +250 -0
- package/src/resolve.ts +387 -0
- package/src/resources.ts +1 -1
- package/src/runtime.ts +454 -18
- package/src/tool-text.ts +64 -30
- package/src/tool.ts +136 -21
- package/src/types.ts +65 -57
- package/src/ui/bootstrap-text.ts +24 -0
- package/src/ui/bootstrap.ts +82 -3
- package/src/ui/index.ts +6 -2
- package/src/ui/mail-message.ts +5 -1
- package/src/ui/viewer-text.ts +116 -4
- package/src/ui/viewer.ts +650 -61
- package/src/urls-text.ts +24 -0
- package/src/urls.ts +206 -33
- package/src/work.ts +10 -0
- package/src/host-text.ts +0 -86
- package/src/node.ts +0 -127
- package/src/store.ts +0 -189
package/src/messages.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { OnlyneError, type DeliveryPlan, type Entry, type Message, type NotifyEvent, type Runtime, type TraceEvent } from "@onlyne/lib";
|
|
1
|
+
import { OnlyneError, type DeliveryPlan, type Entry, type Message, type NotifyEvent, type Runtime, type TraceEvent, type WorkRef } from "@onlyne/lib";
|
|
2
2
|
import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
|
|
3
3
|
import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry";
|
|
4
4
|
import { refreshMailCards, renderMailMessage, type OnlyneMessageDetails } from "./ui/mail-message.ts";
|
|
5
5
|
import type { InboundDecision, JudgeDecider } from "./decider.ts";
|
|
6
|
-
import type { ContextRegistry } from "
|
|
7
|
-
import type { ContextRecord } from "
|
|
6
|
+
import type { ContextRegistry } from "@onlyne/node-host/store";
|
|
7
|
+
import type { ContextRecord } from "@onlyne/node-host/types";
|
|
8
8
|
import { AWAIT_CONSUMED, pendingAwaitResults } from "./tool.ts";
|
|
9
9
|
import { applyMessageTodo, type TodoPlanner } from "./todo.ts";
|
|
10
10
|
import { getRuntime } from "./runtime.ts";
|
|
11
|
+
import { handshakeFacts } from "./handshake.ts";
|
|
11
12
|
import { bridgeText as t } from "./bridge-text.ts";
|
|
12
13
|
|
|
13
14
|
export const INBOUND = "onlyne.inbound";
|
|
@@ -20,6 +21,8 @@ export interface BridgeDeps {
|
|
|
20
21
|
decider: JudgeDecider;
|
|
21
22
|
contexts: ContextRegistry;
|
|
22
23
|
nodeId: string;
|
|
24
|
+
/** Wall clock, so the escalation window can be tested without waiting for it. */
|
|
25
|
+
now?: () => number;
|
|
23
26
|
}
|
|
24
27
|
/**
|
|
25
28
|
* One inbound entry is judged once per process. A retry replays the recorded decision
|
|
@@ -31,8 +34,44 @@ interface InboundAttempt {
|
|
|
31
34
|
decision?: InboundDecision;
|
|
32
35
|
failure?: OnlyneError;
|
|
33
36
|
landed?: string;
|
|
37
|
+
/** Maintenance passes this entry has been reported landed without being confirmed. */
|
|
38
|
+
passes?: number;
|
|
39
|
+
/** Whether the held delivery has already been forced through as a trigger. */
|
|
40
|
+
escalated?: boolean;
|
|
41
|
+
/** Set once the second window passed with no confirmation; the warning is written once. */
|
|
42
|
+
unconfirmed?: boolean;
|
|
43
|
+
/** How the delivery was submitted: a trigger starts work, a next-turn fragment waits. */
|
|
44
|
+
delivery?: { via: "trigger" | "nextTurn"; at: number };
|
|
34
45
|
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How long a next-turn fragment may stay unread before the host offers it again. A turn takes at
|
|
49
|
+
* least one provider round trip, so a window measured in maintenance passes fires while the
|
|
50
|
+
* session is still working and duplicates the delivery (dpdp report, follow_up queue).
|
|
51
|
+
*/
|
|
52
|
+
const NEXT_TURN_ESCALATE_MS = 60_000;
|
|
53
|
+
/** Times an entry may be decided again after its binding went away before it is delivered instead. */
|
|
54
|
+
const MAX_REDECISIONS = 2;
|
|
35
55
|
const inboundAttempts = new Map<string, InboundAttempt>();
|
|
56
|
+
/**
|
|
57
|
+
* Deliveries attempted per entry, kept outside the attempt record: dropping that record is what
|
|
58
|
+
* makes the next pass decide again, and it must not reset the budget that bounds the deciding.
|
|
59
|
+
*/
|
|
60
|
+
const deliveryAttempts = new Map<string, number>();
|
|
61
|
+
|
|
62
|
+
function countedAttempt(key: string): number {
|
|
63
|
+
const next = (deliveryAttempts.get(key) ?? 0) + 1;
|
|
64
|
+
deliveryAttempts.set(key, next);
|
|
65
|
+
return next;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The end of the line for an entry nothing can hold: the main session takes it once, visibly. */
|
|
69
|
+
function escalate(deps: BridgeDeps, entry: Entry, key: string, reason: string) {
|
|
70
|
+
const token = registerPending(entry, deps.main.agent.id, "fragment");
|
|
71
|
+
deps.pi.sendMessage(payload(entry, INBOUND, token), { triggerTurn: true });
|
|
72
|
+
deps.pi.logger.info("onlyne.delivery.escalated", { entry: key, reason, attempts: deliveryAttempts.get(key) });
|
|
73
|
+
return { context: deps.main.agent.id, consumed: false, outcome: "delivered" as const };
|
|
74
|
+
}
|
|
36
75
|
|
|
37
76
|
function inboundKey(entry: Entry): string {
|
|
38
77
|
return `${entry.thread_ref.owner}/${entry.thread_ref.id}/${entry.seq}`;
|
|
@@ -41,6 +80,7 @@ function inboundKey(entry: Entry): string {
|
|
|
41
80
|
const TRACE_DEBUG = new Set(["inbound.inject", "inbound.duplicate"]);
|
|
42
81
|
|
|
43
82
|
export function createRuntimeBridge(deps: BridgeDeps): Runtime {
|
|
83
|
+
const now = deps.now ?? Date.now;
|
|
44
84
|
return {
|
|
45
85
|
/**
|
|
46
86
|
* Admission is the last cheap place to refuse a plan this host cannot honor (RFC 0005
|
|
@@ -56,8 +96,16 @@ export function createRuntimeBridge(deps: BridgeDeps): Runtime {
|
|
|
56
96
|
const fields = { ...event } as Record<string, unknown>;
|
|
57
97
|
const name = String(fields.event);
|
|
58
98
|
delete fields.event;
|
|
59
|
-
|
|
60
|
-
|
|
99
|
+
// `reconcile.failed` is the one event that names its thread as a bare id beside the
|
|
100
|
+
// owner, so formatting it as a reference logged `undefined/undefined` and dropped the
|
|
101
|
+
// id entirely. Every other event carries a real ThreadRef.
|
|
102
|
+
if (name === "reconcile.failed") {
|
|
103
|
+
fields.thread = `${String(fields.owner)}/${String(fields.thread)}`;
|
|
104
|
+
delete fields.owner;
|
|
105
|
+
} else {
|
|
106
|
+
const thread = fields.thread as { owner?: unknown; id?: unknown } | undefined;
|
|
107
|
+
if (thread !== undefined) fields.thread = `${String(thread.owner)}/${String(thread.id)}`;
|
|
108
|
+
}
|
|
61
109
|
if (TRACE_DEBUG.has(name)) deps.pi.logger.debug(`onlyne.${name}`, fields);
|
|
62
110
|
else deps.pi.logger.info(`onlyne.${name}`, fields);
|
|
63
111
|
},
|
|
@@ -87,23 +135,68 @@ export function createRuntimeBridge(deps: BridgeDeps): Runtime {
|
|
|
87
135
|
});
|
|
88
136
|
}
|
|
89
137
|
if (attempt.failure !== undefined) {
|
|
138
|
+
// A lost binding cannot be re-decided into existence: past the budget the entry is
|
|
139
|
+
// delivered to the main session instead, so the judgement stops burning model calls
|
|
140
|
+
// and the message stops being invisible.
|
|
141
|
+
if (attempt.failure.error.code === "context_lost" && countedAttempt(key) > MAX_REDECISIONS) {
|
|
142
|
+
return escalate(deps, entry, key, "context-lost");
|
|
143
|
+
}
|
|
90
144
|
deps.pi.logger.debug("onlyne.delivery.held", { entry: key, reason: "judge-failed", error: attempt.failure.message });
|
|
91
145
|
throw attempt.failure;
|
|
92
146
|
}
|
|
93
147
|
const decision = attempt.decision;
|
|
94
148
|
if (decision === undefined) throw new OnlyneError("decision_unavailable", t("noDecision"));
|
|
95
|
-
if (decision.action === "new_subagent") {
|
|
96
|
-
attempt.landed ??= await startSubagent(deps, entry);
|
|
97
|
-
// A spawn is a submission, not a read: the child's prompt carries the token and its
|
|
98
|
-
// first provider request is the proof, so nothing is consumed here.
|
|
99
|
-
return { context: attempt.landed, consumed: false, outcome: "delivered" as const };
|
|
100
|
-
}
|
|
101
149
|
// The body is already where the decision put it; re-sending it would append a
|
|
102
150
|
// second copy that the cursor can only confirm once.
|
|
103
151
|
if (attempt.landed !== undefined) {
|
|
152
|
+
// RFC 0008 §5: a delivery is landed only while the context holding it can still
|
|
153
|
+
// confirm it. A subagent session that ended leaves the entry unconfirmed forever,
|
|
154
|
+
// and reporting it delivered on every maintenance pass is a retry storm, not a
|
|
155
|
+
// delivery. Mark the dead binding lost and let the next pass decide again.
|
|
156
|
+
const holder = (await deps.contexts.list()).find(record => record.agent_id === attempt.landed);
|
|
157
|
+
if (holder !== undefined && holder.state !== "idle" && holder.state !== "running") {
|
|
158
|
+
if (countedAttempt(key) > MAX_REDECISIONS) return escalate(deps, entry, key, "context-lost");
|
|
159
|
+
await deps.contexts.updateState(holder.alias, "lost").catch(() => undefined);
|
|
160
|
+
inboundAttempts.delete(key);
|
|
161
|
+
throw new OnlyneError("context_lost", t("contextLost", { alias: holder.alias }), { context: holder.alias });
|
|
162
|
+
}
|
|
163
|
+
// A delivery nobody confirmed has two shapes and one bound. A next-turn fragment waits
|
|
164
|
+
// for an assembly the session may never reach. A trigger starts its own turn, and that
|
|
165
|
+
// turn can die before the model reads the payload — a provider response at or above 400
|
|
166
|
+
// drops the observation it recorded — leaving the entry unconfirmed while every pass
|
|
167
|
+
// reports it delivered. Both are retried once, after a window long enough to be sure the
|
|
168
|
+
// session is not simply working, and only while it is idle right now.
|
|
169
|
+
attempt.passes = (attempt.passes ?? 0) + 1;
|
|
170
|
+
const delivery = attempt.delivery;
|
|
171
|
+
const waiting = delivery !== undefined && now() - delivery.at >= NEXT_TURN_ESCALATE_MS;
|
|
172
|
+
if (attempt.landed === deps.main.agent.id && waiting && delivery !== undefined && deps.main.isIdle()) {
|
|
173
|
+
if (attempt.escalated !== true) {
|
|
174
|
+
attempt.escalated = true;
|
|
175
|
+
// The retry opens its own window; measuring the second one from the first send
|
|
176
|
+
// would report it as unconfirmed on the very next pass.
|
|
177
|
+
attempt.delivery = { via: "trigger", at: now() };
|
|
178
|
+
const token = registerPending(entry, deps.main.agent.id, "fragment");
|
|
179
|
+
deps.pi.sendMessage(payload(entry, INBOUND, token), { triggerTurn: true });
|
|
180
|
+
deps.pi.logger.info("onlyne.delivery.escalated", {
|
|
181
|
+
entry: key, reason: "unconfirmed", via: delivery.via, waited_ms: now() - delivery.at,
|
|
182
|
+
});
|
|
183
|
+
} else if (attempt.unconfirmed !== true) {
|
|
184
|
+
// One warning per entry: the reason is recorded once, and the pass stops shouting.
|
|
185
|
+
attempt.unconfirmed = true;
|
|
186
|
+
deps.pi.logger.warn("onlyne.delivery.unconfirmed", {
|
|
187
|
+
entry: key, via: delivery.via, passes: attempt.passes, waited_ms: now() - delivery.at, context: attempt.landed,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
104
191
|
deps.pi.logger.debug("onlyne.delivery.held", { entry: key, reason: "already-delivered", context: attempt.landed });
|
|
105
192
|
return { context: attempt.landed, consumed: false, outcome: "delivered" as const };
|
|
106
193
|
}
|
|
194
|
+
if (decision.action === "new_subagent") {
|
|
195
|
+
attempt.landed = await startSubagent(deps, entry);
|
|
196
|
+
// A spawn is a submission, not a read: the child's prompt carries the token and its
|
|
197
|
+
// first provider request is the proof, so nothing is consumed here.
|
|
198
|
+
return { context: attempt.landed, consumed: false, outcome: "delivered" as const };
|
|
199
|
+
}
|
|
107
200
|
if (decision.action === "context") {
|
|
108
201
|
const alias = decision.context;
|
|
109
202
|
if (alias === undefined) throw new OnlyneError("decision_unavailable", t("noContextNamed"));
|
|
@@ -137,8 +230,15 @@ export function createRuntimeBridge(deps: BridgeDeps): Runtime {
|
|
|
137
230
|
payload(entry, INBOUND, token),
|
|
138
231
|
mode === undefined ? { triggerTurn: true } : { deliverAs: mode === "steer" ? "steer" : "followUp" },
|
|
139
232
|
);
|
|
233
|
+
attempt.delivery = { via: "trigger", at: now() };
|
|
140
234
|
} else {
|
|
141
|
-
|
|
235
|
+
// RFC 0008 §7: a fragment enters the next context assembly without starting a turn.
|
|
236
|
+
// An idle session never reaches that assembly by itself, so the entry would sit in
|
|
237
|
+
// the queue as `injected: false` forever; idle means there is no turn to wait for.
|
|
238
|
+
const idle = deps.main.isIdle();
|
|
239
|
+
deps.pi.sendMessage(payload(entry, INBOUND, token), idle ? { triggerTurn: true } : { deliverAs: "nextTurn" });
|
|
240
|
+
if (idle) deps.pi.logger.info("onlyne.delivery.escalated", { entry: key, reason: "idle-fragment" });
|
|
241
|
+
attempt.delivery = { via: idle ? "trigger" : "nextTurn", at: now() };
|
|
142
242
|
}
|
|
143
243
|
attempt.landed = deps.main.agent.id;
|
|
144
244
|
deps.pi.logger.info("onlyne.delivery.sent", {
|
|
@@ -149,6 +249,13 @@ export function createRuntimeBridge(deps: BridgeDeps): Runtime {
|
|
|
149
249
|
},
|
|
150
250
|
todo: async (entry, context) => {
|
|
151
251
|
inboundAttempts.delete(inboundKey(entry));
|
|
252
|
+
deliveryAttempts.delete(inboundKey(entry));
|
|
253
|
+
// RFC 0018 §3: a handshake status update is delivered for the record, not as work.
|
|
254
|
+
// Only an entry that owes a decision takes the todo decision path.
|
|
255
|
+
if (handshakeFacts(entry.message.meta)?.pending === false) {
|
|
256
|
+
refreshMailCards();
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
152
259
|
const planner: TodoPlanner = {
|
|
153
260
|
decideTodo: (incoming, current) => deps.decider.decideTodo(deps.main, incoming, current),
|
|
154
261
|
};
|
|
@@ -172,8 +279,7 @@ const pending = new Map<string, PendingObservation>();
|
|
|
172
279
|
const toolResults = new Map<string, PendingObservation>();
|
|
173
280
|
const observed = new Map<string, string[]>();
|
|
174
281
|
|
|
175
|
-
function registerPending(entry: Entry, context: string, kind: PendingObservation["kind"]): string {
|
|
176
|
-
const token = `onlyne-${crypto.randomUUID()}`;
|
|
282
|
+
function registerPending(entry: Entry, context: string, kind: PendingObservation["kind"], token = `onlyne-${crypto.randomUUID()}`): string {
|
|
177
283
|
pending.set(token, { entry, context, kind });
|
|
178
284
|
return token;
|
|
179
285
|
}
|
|
@@ -283,7 +389,9 @@ async function startSubagent(deps: BridgeDeps, entry: Entry): Promise<string> {
|
|
|
283
389
|
await deps.contexts.bind(record.alias, { agent_id: child, ...(parentId === undefined ? {} : { parent_id: parentId }) });
|
|
284
390
|
// The token is already in the child's prompt, so it can only be registered once the child's
|
|
285
391
|
// identity is known; until then there is no provider boundary to observe it at.
|
|
286
|
-
|
|
392
|
+
// It is registered as the token the prompt carries: a fresh one would never appear in any
|
|
393
|
+
// provider payload, and the delivery could never be confirmed.
|
|
394
|
+
registerPending(entry, child, "fragment", token);
|
|
287
395
|
return child;
|
|
288
396
|
}
|
|
289
397
|
async function findContextBySource(contexts: ContextRegistry, entry: Entry): Promise<ContextRecord | undefined> {
|
|
@@ -314,6 +422,7 @@ function payload(entry: Entry, customType: string, token: string): MessagePayloa
|
|
|
314
422
|
from: entry.authenticated_author, at: entry.at, subject: entry.message.delivery_kind, body: entry.message.msg,
|
|
315
423
|
...(entry.message.meta?.relation === undefined ? {} : { relation: entry.message.meta.relation }),
|
|
316
424
|
...(entry.message.meta?.in_reply_to === undefined ? {} : { in_reply_to: entry.message.meta.in_reply_to }),
|
|
425
|
+
...(entry.message.meta?.work === undefined ? {} : { work: entry.message.meta.work }),
|
|
317
426
|
},
|
|
318
427
|
} };
|
|
319
428
|
}
|
|
@@ -323,5 +432,14 @@ export function registerMessageRenderers(pi: ExtensionAPI): void {
|
|
|
323
432
|
function renderEntry(entry: Entry, customType: string): string {
|
|
324
433
|
const label = customType === SUPPLEMENT ? t("labelSupplement") : t("labelInbound");
|
|
325
434
|
const relation = entry.message.meta?.relation === undefined ? "" : ` · ${entry.message.meta.relation}`;
|
|
326
|
-
|
|
435
|
+
const work = workLabel(entry.message.meta?.work);
|
|
436
|
+
return `${entry.thread_ref.id}/${entry.seq} · ${label} · ${entry.authenticated_author}${relation}${work}\n${entry.message.msg.split(/\r?\n/).map(line => ` > ${line}`).join("\n")}`;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** The work unit a sender named, so the receiver sees the dedupe key its answer belongs to. */
|
|
440
|
+
export function workLabel(work: WorkRef | undefined): string {
|
|
441
|
+
if (work === undefined) return "";
|
|
442
|
+
const parent = work.parent === undefined || work.parent === null ? "" : ` · parent ${work.parent}`;
|
|
443
|
+
const attempt = work.attempt === undefined ? "" : ` · attempt ${work.attempt}`;
|
|
444
|
+
return ` · work ${work.unit}${parent}${attempt}`;
|
|
327
445
|
}
|
package/src/plan.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { OnlyneError, type Action, type Json, type NodeId, type ThreadRef } from "@onlyne/lib";
|
|
2
|
+
import { text } from "./decider-text.ts";
|
|
3
|
+
import { choice, type Answers } from "./questions.ts";
|
|
4
|
+
import { reuseThread, type ReplyReference, type ReferenceDeps } from "./resolve.ts";
|
|
5
|
+
|
|
6
|
+
export type ResponseMode = "await" | "async" | "none";
|
|
7
|
+
export type ExecutionMode = "existing" | "new";
|
|
8
|
+
|
|
9
|
+
/** The plan fields the caller already fixed in meta. */
|
|
10
|
+
export interface InheritedModes {
|
|
11
|
+
response?: ResponseMode;
|
|
12
|
+
execution?: ExecutionMode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ResourceFields {
|
|
16
|
+
owner: NodeId;
|
|
17
|
+
id: string;
|
|
18
|
+
operation: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function stringField(record: Record<string, Json>, key: string): string | undefined {
|
|
22
|
+
const value = record[key];
|
|
23
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function recordField(record: Record<string, Json>, key: string): Record<string, Json> | undefined {
|
|
27
|
+
const value = record[key];
|
|
28
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, Json>) : undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A plan field the caller stated is binding and is never asked again; a value outside the two
|
|
33
|
+
* vocabularies is a caller error, never a reason to ask the judge. Anything the caller left out
|
|
34
|
+
* is decided by the O-3A question, which is why the omission is returned as `undefined` here
|
|
35
|
+
* rather than filled with a default (audit §2.5).
|
|
36
|
+
*/
|
|
37
|
+
export function inheritedModes(meta: Record<string, Json> | undefined): InheritedModes {
|
|
38
|
+
if (meta === undefined) return {};
|
|
39
|
+
const response = meta.response;
|
|
40
|
+
const execution = meta.execution;
|
|
41
|
+
if (response !== undefined && response !== "await" && response !== "async" && response !== "none") {
|
|
42
|
+
throw new OnlyneError("invalid_request", text("error.meta_mode", { key: "response" }));
|
|
43
|
+
}
|
|
44
|
+
if (execution !== undefined && execution !== "existing" && execution !== "new") {
|
|
45
|
+
throw new OnlyneError("invalid_request", text("error.meta_mode", { key: "execution" }));
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
...(response === undefined ? {} : { response }),
|
|
49
|
+
...(execution === undefined ? {} : { execution }),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The complete plan: what the caller fixed, plus what the judge answered for the rest. */
|
|
54
|
+
export function resolvedModes(inherited: InheritedModes, answers: Answers): { response: ResponseMode; execution: ExecutionMode } {
|
|
55
|
+
const response = inherited.response ?? choice(answers, "response");
|
|
56
|
+
const execution = inherited.execution ?? choice(answers, "execution");
|
|
57
|
+
if (response !== "none" && response !== "async" && response !== "await") {
|
|
58
|
+
throw new OnlyneError("decision_unavailable", text("error.response_mode"));
|
|
59
|
+
}
|
|
60
|
+
if (execution !== "existing" && execution !== "new") {
|
|
61
|
+
throw new OnlyneError("decision_unavailable", text("error.execution_mode"));
|
|
62
|
+
}
|
|
63
|
+
return { response, execution };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The fields a resource operation needs, or `undefined` when the call does not name a complete
|
|
68
|
+
* one. A partial `meta.resource` is not a resource action, and it is not a reason to fall back
|
|
69
|
+
* to communication either: the caller named a resource and must finish naming it.
|
|
70
|
+
*/
|
|
71
|
+
export function resourceFields(meta: Record<string, Json> | undefined): ResourceFields | undefined {
|
|
72
|
+
if (meta === undefined) return undefined;
|
|
73
|
+
const resource = recordField(meta, "resource");
|
|
74
|
+
const owner = resource === undefined ? undefined : stringField(resource, "owner");
|
|
75
|
+
const id = resource === undefined ? undefined : stringField(resource, "id");
|
|
76
|
+
const operation = stringField(meta, "operation");
|
|
77
|
+
return owner === undefined || id === undefined || operation === undefined ? undefined : { owner, id, operation };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The resource action both the explicit and the judged branch build. */
|
|
81
|
+
export function resourceAction(meta: Record<string, Json> | undefined): Action {
|
|
82
|
+
const fields = resourceFields(meta);
|
|
83
|
+
if (fields === undefined) throw new OnlyneError("invalid_request", text("error.resource_action_fields"));
|
|
84
|
+
const data = meta === undefined ? undefined : meta.data;
|
|
85
|
+
const revision = meta === undefined ? undefined : meta.expected_revision;
|
|
86
|
+
return {
|
|
87
|
+
kind: "resource",
|
|
88
|
+
resource: { owner: fields.owner, id: fields.id },
|
|
89
|
+
operation: fields.operation,
|
|
90
|
+
...(data === undefined ? {} : { data }),
|
|
91
|
+
...(typeof revision === "string" ? { expected_revision: revision } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The thread a send belongs to when the caller named none.
|
|
97
|
+
*
|
|
98
|
+
* A reply lands on the thread that holds its original (the client resolves it), and a plan
|
|
99
|
+
* that says `new` opens a topic. Otherwise the message continues the conversation this node
|
|
100
|
+
* already has with its addressee: leaving it unnamed makes the client open a thread per
|
|
101
|
+
* message, and asking the model for an alias it cannot know is the mistake the design already
|
|
102
|
+
* recorded. This is a lookup over local records, not a judgement (RFC 0016 §5).
|
|
103
|
+
*/
|
|
104
|
+
export async function continueThread(deps: ReferenceDeps, action: Action): Promise<Action> {
|
|
105
|
+
if (action.kind !== "send" || action.thread !== undefined) return action;
|
|
106
|
+
if (action.in_reply_to !== undefined || action.execution === "new") return action;
|
|
107
|
+
const thread = await reuseThread(deps, action.to);
|
|
108
|
+
return thread === undefined ? action : { ...action, thread };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** One send action from its resolved references and judged plan. */
|
|
112
|
+
export function sendAction(parts: {
|
|
113
|
+
to: NodeId;
|
|
114
|
+
response: "await" | "async" | "none";
|
|
115
|
+
execution: "existing" | "new";
|
|
116
|
+
thread?: ThreadRef;
|
|
117
|
+
context?: string;
|
|
118
|
+
reply?: ReplyReference;
|
|
119
|
+
relation?: "reply" | "followup";
|
|
120
|
+
mentions?: string[];
|
|
121
|
+
}): Action {
|
|
122
|
+
return {
|
|
123
|
+
kind: "send",
|
|
124
|
+
to: parts.to,
|
|
125
|
+
...(parts.thread === undefined ? {} : { thread: parts.thread }),
|
|
126
|
+
...(parts.context === undefined ? {} : { context: parts.context }),
|
|
127
|
+
...(parts.reply === undefined ? {} : { in_reply_to: parts.reply.ref }),
|
|
128
|
+
...(parts.relation === undefined ? {} : { relation: parts.relation }),
|
|
129
|
+
...(parts.mentions === undefined ? {} : { mentions: parts.mentions }),
|
|
130
|
+
response: parts.response,
|
|
131
|
+
execution: parts.execution,
|
|
132
|
+
};
|
|
133
|
+
}
|
package/src/questions.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import type { NodeId, ThreadRef } from "@onlyne/lib";
|
|
2
|
+
import { text } from "./decider-text.ts";
|
|
3
|
+
import type { PeerRecord } from "./resolve.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The choice catalogs of the decision nodes. Each builder returns the question the judge
|
|
7
|
+
* answers, with only the options that can actually execute: an option whose choice is
|
|
8
|
+
* guaranteed to fail is a question the model cannot answer correctly.
|
|
9
|
+
*/
|
|
10
|
+
export interface ChoiceQuestion {
|
|
11
|
+
type: "choice";
|
|
12
|
+
instructions: string;
|
|
13
|
+
criteria: Record<string, string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type Questions = Record<string, ChoiceQuestion>;
|
|
17
|
+
|
|
18
|
+
export interface ChoiceAnswer {
|
|
19
|
+
type?: string;
|
|
20
|
+
choice?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Answers = Record<string, ChoiceAnswer | undefined>;
|
|
24
|
+
|
|
25
|
+
/** The one answer a choice question carries, or `undefined` when the judge gave none. */
|
|
26
|
+
export function choice(answers: Answers, key: string): string | undefined {
|
|
27
|
+
const answer = answers[key];
|
|
28
|
+
if (answer === undefined || answer === null || typeof answer !== "object") return undefined;
|
|
29
|
+
return typeof answer.choice === "string" ? answer.choice : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Node O-1. `resource` is offered only when meta names a complete resource action. */
|
|
33
|
+
export function intentQuestions(options: { resource: boolean }): Questions {
|
|
34
|
+
return {
|
|
35
|
+
intent: {
|
|
36
|
+
type: "choice",
|
|
37
|
+
instructions: text("judge.o1.instructions"),
|
|
38
|
+
criteria: {
|
|
39
|
+
communicate: text("judge.o1.communicate"),
|
|
40
|
+
inspect: text("judge.o1.inspect"),
|
|
41
|
+
manage_thread: text("judge.o1.manage_thread"),
|
|
42
|
+
manage_domain: text("judge.o1.manage_domain"),
|
|
43
|
+
...(options.resource ? { operate_resource: text("judge.o1.operate_resource") } : {}),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Node O-2A, question 1: the relation a reference carries. */
|
|
50
|
+
export function relationQuestions(): Questions {
|
|
51
|
+
return {
|
|
52
|
+
relation: {
|
|
53
|
+
type: "choice",
|
|
54
|
+
instructions: text("judge.relation.instructions"),
|
|
55
|
+
criteria: {
|
|
56
|
+
reply: text("judge.relation.reply"),
|
|
57
|
+
followup: text("judge.relation.followup"),
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Node O-3A: whichever of response and execution the caller left open. */
|
|
64
|
+
export function planQuestions(open: { response: boolean; execution: boolean; reply: boolean }): Questions {
|
|
65
|
+
return {
|
|
66
|
+
...(open.response
|
|
67
|
+
? {
|
|
68
|
+
response: {
|
|
69
|
+
type: "choice" as const,
|
|
70
|
+
instructions: text("judge.response.instructions"),
|
|
71
|
+
criteria: {
|
|
72
|
+
none: text("judge.response.none"),
|
|
73
|
+
async: text("judge.response.async"),
|
|
74
|
+
await: text("judge.response.await"),
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
}
|
|
78
|
+
: {}),
|
|
79
|
+
...(open.execution
|
|
80
|
+
? {
|
|
81
|
+
execution: {
|
|
82
|
+
type: "choice" as const,
|
|
83
|
+
instructions: text("judge.execution.instructions"),
|
|
84
|
+
criteria: {
|
|
85
|
+
existing: open.reply
|
|
86
|
+
? text("judge.execution.existing.reply")
|
|
87
|
+
: text("judge.execution.existing.new"),
|
|
88
|
+
new: text("judge.execution.new"),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
: {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Node O-3A-Ctx: which published context of the target continues the work. */
|
|
97
|
+
export function contextQuestions(published: Array<{ alias: string; summary: string; state: string }>): Questions {
|
|
98
|
+
return {
|
|
99
|
+
context: {
|
|
100
|
+
type: "choice",
|
|
101
|
+
instructions: text("judge.context.instructions"),
|
|
102
|
+
criteria: {
|
|
103
|
+
...Object.fromEntries(published.map(context => [
|
|
104
|
+
context.alias,
|
|
105
|
+
text("judge.context.published", { summary: context.summary, state: context.state }),
|
|
106
|
+
])),
|
|
107
|
+
none: text("judge.context.none"),
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Node O-2A, question 2: the recipient among the peers this node can name. */
|
|
114
|
+
export function targetQuestions(peers: PeerRecord[]): Questions {
|
|
115
|
+
return {
|
|
116
|
+
target: {
|
|
117
|
+
type: "choice",
|
|
118
|
+
instructions: text("judge.target.instructions"),
|
|
119
|
+
criteria: {
|
|
120
|
+
// The abstain exit: an unnamed recipient must surface as ambiguous_target,
|
|
121
|
+
// never as a low-confidence random pick among peers.
|
|
122
|
+
none: text("judge.target.none"),
|
|
123
|
+
...Object.fromEntries(peers.map(peer => [
|
|
124
|
+
peer.node_id,
|
|
125
|
+
text("judge.target.peer", {
|
|
126
|
+
node: peer.node_id,
|
|
127
|
+
base_url: peer.base_url,
|
|
128
|
+
instance: peer.instance_id,
|
|
129
|
+
last_seen: peer.last_seen,
|
|
130
|
+
}),
|
|
131
|
+
])),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Node O-3B-Th: which local thread a read or thread operation names. */
|
|
138
|
+
export function threadQuestions(threads: Array<{ ref: ThreadRef; title: string; state: string }>): Questions {
|
|
139
|
+
return {
|
|
140
|
+
thread: {
|
|
141
|
+
type: "choice",
|
|
142
|
+
instructions: text("judge.thread.instructions"),
|
|
143
|
+
criteria: Object.fromEntries(threads.map(thread => [
|
|
144
|
+
thread.ref.id,
|
|
145
|
+
text("judge.thread.item", {
|
|
146
|
+
title: thread.title,
|
|
147
|
+
owner: thread.ref.owner,
|
|
148
|
+
id: thread.ref.id,
|
|
149
|
+
state: thread.state,
|
|
150
|
+
}),
|
|
151
|
+
])),
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Node O-2A: which unanswered inbound message this call answers, or none. */
|
|
157
|
+
export function originalQuestions(
|
|
158
|
+
facts: Array<{ key: string; from: NodeId; kind: string; thread: string; seq: string; body: string }>,
|
|
159
|
+
): Questions {
|
|
160
|
+
const criteria: Record<string, string> = { new_message: text("judge.original.new") };
|
|
161
|
+
for (const fact of facts) {
|
|
162
|
+
criteria[fact.key] = text("judge.original.candidate", {
|
|
163
|
+
from: fact.from, kind: fact.kind, thread: fact.thread, seq: fact.seq, body: fact.body,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
return { original: { type: "choice", instructions: text("judge.original.instructions"), criteria } };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Node O-2B: which readable channel the message asks about. */
|
|
170
|
+
export function readScopeQuestions(): Questions {
|
|
171
|
+
return {
|
|
172
|
+
read_scope: {
|
|
173
|
+
type: "choice",
|
|
174
|
+
instructions: text("judge.read_scope.instructions"),
|
|
175
|
+
criteria: {
|
|
176
|
+
peers: text("judge.read_scope.peers"),
|
|
177
|
+
inbox: text("judge.read_scope.inbox"),
|
|
178
|
+
messages: text("judge.read_scope.messages"),
|
|
179
|
+
evidence: text("judge.read_scope.evidence"),
|
|
180
|
+
resources: text("judge.read_scope.resources"),
|
|
181
|
+
none: text("judge.read_scope.none"),
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Node O-2C: the local thread operation. */
|
|
188
|
+
export function threadOpQuestions(): Questions {
|
|
189
|
+
return {
|
|
190
|
+
thread_op: {
|
|
191
|
+
type: "choice",
|
|
192
|
+
instructions: text("judge.thread_op.instructions"),
|
|
193
|
+
criteria: {
|
|
194
|
+
create: text("judge.thread_op.create"),
|
|
195
|
+
close: text("judge.thread_op.close"),
|
|
196
|
+
list: text("judge.thread_op.list"),
|
|
197
|
+
describe: text("judge.thread_op.describe"),
|
|
198
|
+
member_add: text("judge.thread_op.member_add"),
|
|
199
|
+
member_remove: text("judge.thread_op.member_remove"),
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Node O-2D: the domain topology operation. */
|
|
206
|
+
export function domainOpQuestions(): Questions {
|
|
207
|
+
return {
|
|
208
|
+
domain_op: {
|
|
209
|
+
type: "choice",
|
|
210
|
+
instructions: text("judge.domain_op.instructions"),
|
|
211
|
+
criteria: {
|
|
212
|
+
create: text("judge.domain_op.create"),
|
|
213
|
+
join: text("judge.domain_op.join"),
|
|
214
|
+
leave: text("judge.domain_op.leave"),
|
|
215
|
+
list: text("judge.domain_op.list"),
|
|
216
|
+
describe: text("judge.domain_op.describe"),
|
|
217
|
+
discover: text("judge.domain_op.discover"),
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Node I-0: whether this node owes the sender an answer. */
|
|
224
|
+
export function needsAnswerQuestions(): Questions {
|
|
225
|
+
return {
|
|
226
|
+
needs_answer: {
|
|
227
|
+
type: "choice",
|
|
228
|
+
instructions: text("judge.needs_answer.instructions"),
|
|
229
|
+
criteria: {
|
|
230
|
+
yes: text("judge.needs_answer.yes"),
|
|
231
|
+
no: text("judge.needs_answer.no"),
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Node I-2: how a main-session delivery enters the agent's work. */
|
|
238
|
+
export function insertionQuestions(options: { fragment: boolean }): Questions {
|
|
239
|
+
return {
|
|
240
|
+
insertion_mode: {
|
|
241
|
+
type: "choice",
|
|
242
|
+
instructions: text("judge.insertion.instructions"),
|
|
243
|
+
criteria: {
|
|
244
|
+
steer: text("judge.insertion.steer"),
|
|
245
|
+
follow_up: text("judge.insertion.follow_up"),
|
|
246
|
+
...(options.fragment ? { fragment: text("judge.insertion.fragment") } : {}),
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|