@sigrun/sigrun 0.7.0 → 0.9.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/CHANGELOG.md +78 -59
- package/README.md +32 -0
- package/dist/config.js +47 -0
- package/dist/curation/host.js +125 -21
- package/dist/curation/runs.js +15 -4
- package/dist/extension.d.ts +331 -4
- package/dist/harness/boot.js +114 -3
- package/dist/harness/complete.js +127 -0
- package/dist/harness/decision-tool.js +93 -0
- package/dist/harness/decisions.js +477 -0
- package/dist/harness/event-bus.js +12 -0
- package/dist/harness/interactions.js +81 -0
- package/dist/harness/interactive-tools.js +97 -0
- package/dist/harness/loader.js +285 -1
- package/dist/harness/packages.js +7 -0
- package/dist/harness/published-hooks.js +208 -0
- package/dist/harness/seam-host.js +149 -1
- package/dist/harness/settings.js +41 -0
- package/dist/history/git.js +6 -2
- package/dist/jobs/fire.js +10 -0
- package/dist/jobs/observe.js +27 -2
- package/dist/notices/events.js +8 -0
- package/dist/notices/service.js +297 -0
- package/dist/notices/store.js +331 -0
- package/dist/notices/validate.js +311 -0
- package/dist/server/conversation-ops.js +50 -0
- package/dist/server/core-views/skills-tree.js +3 -12
- package/dist/server/core-views/skills.js +11 -23
- package/dist/server/host-bridge.js +62 -1
- package/dist/server/index.js +26 -0
- package/dist/server/routes/events.js +20 -0
- package/dist/server/routes/extensions.js +5 -0
- package/dist/server/routes/interactions.js +48 -1
- package/dist/server/routes/meta.js +17 -0
- package/dist/server/routes/notices.js +94 -0
- package/dist/server/routes/providers.js +65 -8
- package/dist/server/routes/submissions.js +3 -0
- package/dist/server/routes/users.js +4 -0
- package/dist/server/surfaces/dedup.js +6 -0
- package/dist/server/visibility.js +30 -0
- package/dist/settings/registry.js +18 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
- package/node_modules/@sigrun/shared/dist/decisions.d.ts +95 -0
- package/node_modules/@sigrun/shared/dist/decisions.js +16 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/notices.d.ts +112 -0
- package/node_modules/@sigrun/shared/dist/notices.js +49 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +32 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +63 -0
- package/node_modules/@sigrun/shared/package.json +9 -1
- package/package.json +7 -7
- package/webui/dist/assets/index-D5KowS7k.css +1 -0
- package/webui/dist/assets/index-DGIrLC6F.js +55 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-BI5k-Oci.js +0 -55
- package/webui/dist/assets/index-EGluyRXk.css +0 -1
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Warden Task 5: the decision-provider core — typebox schemas for the
|
|
3
|
+
* decision wire shape, request/answer validators, and the pure
|
|
4
|
+
* {@link buildDecisionRegistry} (fixture-testable; the loader/boot
|
|
5
|
+
* wiring lands in Task 6). The wire TYPES live in
|
|
6
|
+
* `@sigrun/shared/decisions`; the schemas live here because the shared
|
|
7
|
+
* leaf is import-free.
|
|
8
|
+
*
|
|
9
|
+
* `evaluate` NEVER throws: every failure is a `DecisionResult`
|
|
10
|
+
* `ok:false` with one of the spec's codes. Error text never carries the
|
|
11
|
+
* credential (scrubbed) nor the submitted request.
|
|
12
|
+
*/
|
|
13
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
14
|
+
import { Compile } from "typebox/compile";
|
|
15
|
+
import { DECISION_MAX_BYTES, DECISION_MAX_QUESTIONS, } from "@sigrun/shared/decisions";
|
|
16
|
+
// A strict-JSON value schema. `Type.Unknown()` leaves a node unchecked;
|
|
17
|
+
// depth/self-reference aren't expressible without a recursive builder the
|
|
18
|
+
// pi-ai Type mirror doesn't carry, so deep validation happens in
|
|
19
|
+
// `assertJsonValue` below (the wire contract is strict JSON).
|
|
20
|
+
const JsonValue = Type.Unknown();
|
|
21
|
+
/** The depth/self-reference bound for {@link assertJsonValue}. */
|
|
22
|
+
const JSON_MAX_DEPTH = 64;
|
|
23
|
+
/**
|
|
24
|
+
* Throw unless `value` is a strict-JSON value (finite numbers,
|
|
25
|
+
* plain objects/arrays only). The typebox schema admits unknowns;
|
|
26
|
+
* this pass keeps the wire contract strict.
|
|
27
|
+
*/
|
|
28
|
+
function assertJsonValue(value, path, depth) {
|
|
29
|
+
if (depth > JSON_MAX_DEPTH) {
|
|
30
|
+
throw new Error(`${path}: JSON nesting exceeds ${JSON_MAX_DEPTH}`);
|
|
31
|
+
}
|
|
32
|
+
if (value === null || typeof value === "boolean" || typeof value === "string")
|
|
33
|
+
return;
|
|
34
|
+
if (typeof value === "number") {
|
|
35
|
+
if (!Number.isFinite(value))
|
|
36
|
+
throw new Error(`${path}: a number must be finite`);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
for (let i = 0; i < value.length; i++)
|
|
41
|
+
assertJsonValue(value[i], `${path}/${i}`, depth + 1);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (typeof value === "object") {
|
|
45
|
+
const proto = Object.getPrototypeOf(value);
|
|
46
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
47
|
+
throw new Error(`${path}: not a plain JSON object`);
|
|
48
|
+
}
|
|
49
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
50
|
+
assertJsonValue(entry, `${path}/${key}`, depth + 1);
|
|
51
|
+
}
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`${path}: not a JSON value`);
|
|
55
|
+
}
|
|
56
|
+
const NoulQuestion = Type.Object({
|
|
57
|
+
type: Type.Literal("noul"),
|
|
58
|
+
instructions: Type.Optional(JsonValue),
|
|
59
|
+
criteria: Type.Optional(Type.Union([
|
|
60
|
+
Type.Object({
|
|
61
|
+
true: Type.Optional(JsonValue),
|
|
62
|
+
false: Type.Optional(JsonValue),
|
|
63
|
+
}),
|
|
64
|
+
Type.Null(),
|
|
65
|
+
])),
|
|
66
|
+
});
|
|
67
|
+
const ChoiceQuestion = Type.Object({
|
|
68
|
+
type: Type.Literal("choice"),
|
|
69
|
+
instructions: Type.Optional(JsonValue),
|
|
70
|
+
criteria: Type.Record(Type.String(), Type.Union([JsonValue, Type.Null()])),
|
|
71
|
+
});
|
|
72
|
+
const ScoreQuestion = Type.Object({
|
|
73
|
+
type: Type.Literal("score"),
|
|
74
|
+
instructions: Type.Optional(JsonValue),
|
|
75
|
+
criteria: Type.Array(JsonValue),
|
|
76
|
+
});
|
|
77
|
+
const DecisionQuestionSchema = Type.Union([NoulQuestion, ChoiceQuestion, ScoreQuestion]);
|
|
78
|
+
const DecisionRequestSchema = Type.Object({
|
|
79
|
+
state: Type.Union([Type.String(), Type.Record(Type.String(), JsonValue), Type.Array(JsonValue)]),
|
|
80
|
+
questions: Type.Record(Type.String(), DecisionQuestionSchema),
|
|
81
|
+
});
|
|
82
|
+
const Probability = Type.Number({ minimum: 0, maximum: 1 });
|
|
83
|
+
const DecisionAnswerSchema = Type.Union([
|
|
84
|
+
Type.Object({ type: Type.Literal("noul"), noul: Probability }),
|
|
85
|
+
Type.Object({
|
|
86
|
+
type: Type.Literal("choice"),
|
|
87
|
+
choice: Type.String(),
|
|
88
|
+
confidence: Probability,
|
|
89
|
+
probabilities: Type.Record(Type.String(), Probability),
|
|
90
|
+
}),
|
|
91
|
+
Type.Object({
|
|
92
|
+
type: Type.Literal("score"),
|
|
93
|
+
score: Type.Number(),
|
|
94
|
+
confidence: Probability,
|
|
95
|
+
probabilities: Type.Record(Type.String(), Probability),
|
|
96
|
+
legend: Type.Optional(Type.Record(Type.String(), JsonValue)),
|
|
97
|
+
}),
|
|
98
|
+
]);
|
|
99
|
+
const DecisionAnswersSchema = Type.Object({
|
|
100
|
+
answers: Type.Record(Type.String(), DecisionAnswerSchema),
|
|
101
|
+
usage: Type.Optional(Type.Object({
|
|
102
|
+
input_tokens: Type.Union([Type.Integer(), Type.Null()]),
|
|
103
|
+
output_tokens: Type.Union([Type.Integer(), Type.Null()]),
|
|
104
|
+
})),
|
|
105
|
+
model: Type.Optional(Type.String()),
|
|
106
|
+
});
|
|
107
|
+
const requestValidator = Compile(DecisionRequestSchema);
|
|
108
|
+
const answersValidator = Compile(DecisionAnswersSchema);
|
|
109
|
+
function describeErrors(validator, value) {
|
|
110
|
+
const out = [];
|
|
111
|
+
for (const err of validator.Errors(value)) {
|
|
112
|
+
out.push({ path: err.instancePath, message: err.message ?? "invalid" });
|
|
113
|
+
}
|
|
114
|
+
return out.length > 0 ? out : [{ path: "", message: "invalid" }];
|
|
115
|
+
}
|
|
116
|
+
function questionEntries(value) {
|
|
117
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
118
|
+
return [];
|
|
119
|
+
const questions = value.questions;
|
|
120
|
+
if (typeof questions !== "object" || questions === null || Array.isArray(questions))
|
|
121
|
+
return [];
|
|
122
|
+
return Object.entries(questions);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Validate an unknown value as a {@link DecisionRequest}: schema shape
|
|
126
|
+
* first (`invalid_request`, errors carry `instancePath`), then the
|
|
127
|
+
* limits (`over_limits`) — the built-in caps, narrowed by the model's
|
|
128
|
+
* declared `limits`.
|
|
129
|
+
*/
|
|
130
|
+
export function validateDecisionRequest(value, limits) {
|
|
131
|
+
if (!requestValidator.Check(value)) {
|
|
132
|
+
return { ok: false, code: "invalid_request", errors: describeErrors(requestValidator, value) };
|
|
133
|
+
}
|
|
134
|
+
// The WHOLE request (state + questions) must be strict JSON — a
|
|
135
|
+
// function or undefined hiding in criteria/instructions must never
|
|
136
|
+
// reach the provider.
|
|
137
|
+
try {
|
|
138
|
+
assertJsonValue(value, "", 0);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
return { ok: false, code: "invalid_request", errors: [{ path: "", message: errorMessage(err) }] };
|
|
142
|
+
}
|
|
143
|
+
const request = value;
|
|
144
|
+
const entries = questionEntries(value);
|
|
145
|
+
for (const [key, question] of entries) {
|
|
146
|
+
const q = questionOf(key, question);
|
|
147
|
+
if (!q) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
code: "invalid_request",
|
|
151
|
+
errors: [{ path: `/questions/${key}`, message: "unknown question type" }],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const minChoiceOptions = limits?.minChoiceOptions ?? 1;
|
|
155
|
+
if (q.type === "choice" && Object.keys(q.criteria).length < minChoiceOptions) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
code: "invalid_request",
|
|
159
|
+
errors: [
|
|
160
|
+
{
|
|
161
|
+
path: `/questions/${key}/criteria`,
|
|
162
|
+
message: `a choice needs at least ${minChoiceOptions} label${minChoiceOptions === 1 ? "" : "s"}`,
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
const maxScore = limits?.maxScoreLevels ?? Number.POSITIVE_INFINITY;
|
|
168
|
+
const minScore = 2;
|
|
169
|
+
const levels = q.type === "score" ? q.criteria.length : minScore;
|
|
170
|
+
if (q.type === "score" && (levels < minScore || levels > maxScore)) {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
code: "invalid_request",
|
|
174
|
+
errors: [
|
|
175
|
+
{
|
|
176
|
+
path: `/questions/${key}/criteria`,
|
|
177
|
+
message: levels < minScore
|
|
178
|
+
? "a score needs at least 2 levels"
|
|
179
|
+
: `a score has ${levels} levels; the cap is ${maxScore}`,
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const maxQuestions = Math.min(limits?.maxQuestions ?? DECISION_MAX_QUESTIONS, DECISION_MAX_QUESTIONS);
|
|
186
|
+
if (entries.length < 1) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false,
|
|
189
|
+
code: "over_limits",
|
|
190
|
+
errors: [{ path: "/questions", message: "a request needs at least 1 question" }],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (entries.length > maxQuestions) {
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
code: "over_limits",
|
|
197
|
+
errors: [
|
|
198
|
+
{ path: "/questions", message: `${entries.length} questions; the cap is ${maxQuestions}` },
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const maxBytes = Math.min(limits?.maxInputBytes ?? DECISION_MAX_BYTES, DECISION_MAX_BYTES);
|
|
203
|
+
const stateBytes = byteLength(JSON.stringify(value.state));
|
|
204
|
+
if (stateBytes > maxBytes) {
|
|
205
|
+
return {
|
|
206
|
+
ok: false,
|
|
207
|
+
code: "over_limits",
|
|
208
|
+
errors: [
|
|
209
|
+
{ path: "/state", message: `the state is ${stateBytes} bytes; the cap is ${maxBytes}` },
|
|
210
|
+
],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
return { ok: true, request };
|
|
214
|
+
}
|
|
215
|
+
function byteLength(text) {
|
|
216
|
+
return Buffer.byteLength(text, "utf8");
|
|
217
|
+
}
|
|
218
|
+
function questionOf(_key, question) {
|
|
219
|
+
if (typeof question !== "object" || question === null)
|
|
220
|
+
return undefined;
|
|
221
|
+
const q = question;
|
|
222
|
+
if (q.type === "noul")
|
|
223
|
+
return { type: "noul" };
|
|
224
|
+
if (q.type === "choice" && typeof q.criteria === "object" && q.criteria !== null && !Array.isArray(q.criteria)) {
|
|
225
|
+
return { type: "choice", criteria: q.criteria };
|
|
226
|
+
}
|
|
227
|
+
if (q.type === "score" && Array.isArray(q.criteria)) {
|
|
228
|
+
return { type: "score", criteria: q.criteria };
|
|
229
|
+
}
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Validate an unknown value as the {@link DecisionAnswers} for
|
|
234
|
+
* `request`: the schema shape, then coverage — every question answered
|
|
235
|
+
* with the matching type (probabilities are already range-checked by the
|
|
236
|
+
* schema).
|
|
237
|
+
*/
|
|
238
|
+
export function validateDecisionAnswers(request, value) {
|
|
239
|
+
if (!answersValidator.Check(value)) {
|
|
240
|
+
const first = describeErrors(answersValidator, value)[0];
|
|
241
|
+
return { ok: false, error: `answers fail the wire schema at ${first?.path ?? ""}: ${first?.message ?? "invalid"}` };
|
|
242
|
+
}
|
|
243
|
+
const answers = value;
|
|
244
|
+
for (const [key, question] of Object.entries(request.questions)) {
|
|
245
|
+
const answer = answers.answers[key];
|
|
246
|
+
if (answer === undefined) {
|
|
247
|
+
return { ok: false, error: `missing an answer for question "${key}"` };
|
|
248
|
+
}
|
|
249
|
+
if (answer.type !== question.type) {
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
error: `question "${key}" is ${question.type} but its answer is ${answer.type}`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return { ok: true, answers };
|
|
257
|
+
}
|
|
258
|
+
function errorMessage(err) {
|
|
259
|
+
if (err instanceof Error)
|
|
260
|
+
return err.message;
|
|
261
|
+
try {
|
|
262
|
+
return String(err);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return "non-Error throw";
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function readiness(contribution) {
|
|
269
|
+
try {
|
|
270
|
+
return contribution.readiness?.().status ?? "ready";
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return "degraded";
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Build the pure decision registry. Last-registered wins per provider id
|
|
278
|
+
* (each loser warns once, naming both); `evaluate` resolves the model,
|
|
279
|
+
* validates the request against the model's limits, resolves the
|
|
280
|
+
* credential (core decides `not_configured` — never the tracker), runs
|
|
281
|
+
* the provider under `min(timeoutMs, timeoutCapMs)` linked to the
|
|
282
|
+
* caller's signal, and validates the answers. It never throws.
|
|
283
|
+
*/
|
|
284
|
+
export function buildDecisionRegistry(declarations, deps) {
|
|
285
|
+
// Last-registered wins per id (the chat-provider collision rule).
|
|
286
|
+
const entries = new Map();
|
|
287
|
+
const collisionList = [];
|
|
288
|
+
for (const declaration of declarations) {
|
|
289
|
+
const id = declaration.contribution.id;
|
|
290
|
+
const existing = entries.get(id);
|
|
291
|
+
if (existing) {
|
|
292
|
+
deps.warn(`decision provider "${id}": ${declaration.extension} wins over ${existing.declaration.extension} (last-registered wins)`);
|
|
293
|
+
const recorded = collisionList.find((c) => c.id === id);
|
|
294
|
+
if (recorded) {
|
|
295
|
+
recorded.losers.push(recorded.winner);
|
|
296
|
+
recorded.winner = declaration.extension;
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
collisionList.push({
|
|
300
|
+
id,
|
|
301
|
+
winner: declaration.extension,
|
|
302
|
+
losers: [existing.declaration.extension],
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
entries.set(id, {
|
|
307
|
+
declaration,
|
|
308
|
+
models: new Map(declaration.contribution.models.map((m) => [m.id, m])),
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
function resolveModel(spec) {
|
|
312
|
+
if (spec !== undefined) {
|
|
313
|
+
const slash = spec.indexOf("/");
|
|
314
|
+
if (slash < 0)
|
|
315
|
+
return undefined;
|
|
316
|
+
const providerId = spec.slice(0, slash);
|
|
317
|
+
const modelId = spec.slice(slash + 1);
|
|
318
|
+
const entry = entries.get(providerId);
|
|
319
|
+
if (!entry || !entry.models.has(modelId))
|
|
320
|
+
return undefined;
|
|
321
|
+
return { entry, modelId, full: `${providerId}/${modelId}` };
|
|
322
|
+
}
|
|
323
|
+
const first = entries.values().next().value;
|
|
324
|
+
if (!first)
|
|
325
|
+
return undefined;
|
|
326
|
+
const firstModel = first.declaration.contribution.models[0];
|
|
327
|
+
if (!firstModel)
|
|
328
|
+
return undefined;
|
|
329
|
+
return { entry: first, modelId: firstModel.id, full: `${first.declaration.contribution.id}/${firstModel.id}` };
|
|
330
|
+
}
|
|
331
|
+
function settingsTimeoutMs() {
|
|
332
|
+
const decisions = deps.readSettings().server
|
|
333
|
+
?.decisions;
|
|
334
|
+
// Env tier first (env always wins), then the live file value,
|
|
335
|
+
// then the configured fallback.
|
|
336
|
+
return deps.timeoutMs ?? decisions?.timeoutMs ?? deps.timeoutCapMs;
|
|
337
|
+
}
|
|
338
|
+
async function evaluate(caller, request, opts) {
|
|
339
|
+
// 1. Resolve the model: opts.model, else the live decisionModel
|
|
340
|
+
// setting, else the first model of the first-registered provider.
|
|
341
|
+
const settings = deps.readSettings();
|
|
342
|
+
const spec = opts.model ?? settings.decisionModel;
|
|
343
|
+
if (spec === undefined && entries.size === 0) {
|
|
344
|
+
return { ok: false, code: "no_model", error: "no decision provider is registered" };
|
|
345
|
+
}
|
|
346
|
+
const resolved = resolveModel(spec);
|
|
347
|
+
if (!resolved) {
|
|
348
|
+
return spec !== undefined
|
|
349
|
+
? { ok: false, code: "unknown_model", error: `unknown decision model "${spec}"` }
|
|
350
|
+
: { ok: false, code: "no_model", error: "no decision provider is registered" };
|
|
351
|
+
}
|
|
352
|
+
// 2. Validate the request against the model's declared limits.
|
|
353
|
+
const modelInfo = resolved.entry.models.get(resolved.modelId);
|
|
354
|
+
const validation = validateDecisionRequest(request, modelInfo?.limits);
|
|
355
|
+
if (!validation.ok) {
|
|
356
|
+
const first = validation.errors[0];
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
code: validation.code,
|
|
360
|
+
error: `${validation.code} at ${first?.path ?? ""}: ${first?.message ?? "invalid"}`,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
// 3. Credential — core decides not_configured, never the tracker.
|
|
364
|
+
const contribution = resolved.entry.declaration.contribution;
|
|
365
|
+
const credential = deps.credential(contribution.id, contribution.credentialEnv);
|
|
366
|
+
if (credential === undefined || credential === "") {
|
|
367
|
+
return {
|
|
368
|
+
ok: false,
|
|
369
|
+
code: "not_configured",
|
|
370
|
+
error: `no credential for decision provider "${contribution.id}"`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
// 4. Run under min(timeoutMs, cap), linked to the caller's signal.
|
|
374
|
+
const deadlineMs = Math.min(opts.timeoutMs ?? settingsTimeoutMs(), deps.timeoutCapMs);
|
|
375
|
+
const controller = new AbortController();
|
|
376
|
+
let timedOut = false;
|
|
377
|
+
let callerAborted = false;
|
|
378
|
+
const timer = setTimeout(() => {
|
|
379
|
+
timedOut = true;
|
|
380
|
+
controller.abort();
|
|
381
|
+
}, deadlineMs);
|
|
382
|
+
const onCallerAbort = () => {
|
|
383
|
+
callerAborted = true;
|
|
384
|
+
controller.abort();
|
|
385
|
+
};
|
|
386
|
+
opts.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
387
|
+
// A signal aborted before registration never fires the listener —
|
|
388
|
+
// fire the handler immediately so the provider is never called.
|
|
389
|
+
if (opts.signal?.aborted === true)
|
|
390
|
+
onCallerAbort();
|
|
391
|
+
if (callerAborted) {
|
|
392
|
+
clearTimeout(timer);
|
|
393
|
+
opts.signal?.removeEventListener("abort", onCallerAbort);
|
|
394
|
+
return { ok: false, code: "aborted", error: "aborted by the caller" };
|
|
395
|
+
}
|
|
396
|
+
const started = deps.now();
|
|
397
|
+
let value;
|
|
398
|
+
try {
|
|
399
|
+
value = await contribution.evaluate(validation.request, {
|
|
400
|
+
model: resolved.modelId,
|
|
401
|
+
credential,
|
|
402
|
+
signal: controller.signal,
|
|
403
|
+
caller,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
catch (err) {
|
|
407
|
+
const raw = errorMessage(err);
|
|
408
|
+
const scrubbed = credential === "" ? raw : raw.replaceAll(credential, "[redacted]");
|
|
409
|
+
if (timedOut) {
|
|
410
|
+
return { ok: false, code: "timeout", error: `timed out after ${deadlineMs}ms` };
|
|
411
|
+
}
|
|
412
|
+
if (callerAborted) {
|
|
413
|
+
return { ok: false, code: "aborted", error: "aborted by the caller" };
|
|
414
|
+
}
|
|
415
|
+
return { ok: false, code: "failed", error: scrubbed };
|
|
416
|
+
}
|
|
417
|
+
finally {
|
|
418
|
+
clearTimeout(timer);
|
|
419
|
+
opts.signal?.removeEventListener("abort", onCallerAbort);
|
|
420
|
+
}
|
|
421
|
+
if (timedOut) {
|
|
422
|
+
return { ok: false, code: "timeout", error: `timed out after ${deadlineMs}ms` };
|
|
423
|
+
}
|
|
424
|
+
if (callerAborted) {
|
|
425
|
+
return { ok: false, code: "aborted", error: "aborted by the caller" };
|
|
426
|
+
}
|
|
427
|
+
// 5. Validate the answers.
|
|
428
|
+
const answersValidation = validateDecisionAnswers(validation.request, value);
|
|
429
|
+
if (!answersValidation.ok) {
|
|
430
|
+
return { ok: false, code: "bad_answer", error: answersValidation.error };
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
ok: true,
|
|
434
|
+
model: resolved.full,
|
|
435
|
+
answers: answersValidation.answers.answers,
|
|
436
|
+
usage: {
|
|
437
|
+
input_tokens: answersValidation.answers.usage?.input_tokens ?? null,
|
|
438
|
+
output_tokens: answersValidation.answers.usage?.output_tokens ?? null,
|
|
439
|
+
},
|
|
440
|
+
elapsedMs: deps.now() - started,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
evaluate,
|
|
445
|
+
list() {
|
|
446
|
+
const out = [];
|
|
447
|
+
for (const entry of entries.values()) {
|
|
448
|
+
const contribution = entry.declaration.contribution;
|
|
449
|
+
const status = readiness(contribution);
|
|
450
|
+
for (const model of contribution.models) {
|
|
451
|
+
out.push({ model: `${contribution.id}/${model.id}`, provider: contribution.id, status });
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
return out;
|
|
455
|
+
},
|
|
456
|
+
providers() {
|
|
457
|
+
return [...entries.values()].map((entry) => ({
|
|
458
|
+
id: entry.declaration.contribution.id,
|
|
459
|
+
label: entry.declaration.contribution.label,
|
|
460
|
+
extension: entry.declaration.extension,
|
|
461
|
+
models: entry.declaration.contribution.models.map((m) => m.id),
|
|
462
|
+
status: readiness(entry.declaration.contribution),
|
|
463
|
+
}));
|
|
464
|
+
},
|
|
465
|
+
resolvedDefault() {
|
|
466
|
+
const settings = deps.readSettings();
|
|
467
|
+
if (settings.decisionModel !== undefined)
|
|
468
|
+
return settings.decisionModel;
|
|
469
|
+
const first = entries.values().next().value;
|
|
470
|
+
const firstModel = first?.declaration.contribution.models[0];
|
|
471
|
+
return first && firstModel ? `${first.declaration.contribution.id}/${firstModel.id}` : undefined;
|
|
472
|
+
},
|
|
473
|
+
collisions() {
|
|
474
|
+
return collisionList;
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
}
|
|
@@ -772,6 +772,12 @@ export class EventBus {
|
|
|
772
772
|
const recordAnsweredByUser = typeof record.answeredByUser === "string"
|
|
773
773
|
? record.answeredByUser
|
|
774
774
|
: undefined;
|
|
775
|
+
// Ask-user Task 1: the decline channel rides the payload
|
|
776
|
+
// next to the answering user (same record read).
|
|
777
|
+
const recordDeclinedVia = record.declinedVia === "button" ||
|
|
778
|
+
record.declinedVia === "message"
|
|
779
|
+
? record.declinedVia
|
|
780
|
+
: undefined;
|
|
775
781
|
const recordAnswerValues = typeof record.answer === "object" &&
|
|
776
782
|
record.answer !== null &&
|
|
777
783
|
typeof record.answer.values ===
|
|
@@ -808,6 +814,9 @@ export class EventBus {
|
|
|
808
814
|
...(recordAnsweredByUser === undefined
|
|
809
815
|
? {}
|
|
810
816
|
: { answeredByUser: recordAnsweredByUser }),
|
|
817
|
+
...(recordDeclinedVia === undefined
|
|
818
|
+
? {}
|
|
819
|
+
: { declinedVia: recordDeclinedVia }),
|
|
811
820
|
...(recordAnswerValues === undefined
|
|
812
821
|
? {}
|
|
813
822
|
: { answer: recordAnswerValues }),
|
|
@@ -1204,6 +1213,9 @@ export class EventBus {
|
|
|
1204
1213
|
...(event.answeredByUser === undefined
|
|
1205
1214
|
? {}
|
|
1206
1215
|
: { answeredByUser: event.answeredByUser }),
|
|
1216
|
+
...(event.declinedVia === undefined
|
|
1217
|
+
? {}
|
|
1218
|
+
: { declinedVia: event.declinedVia }),
|
|
1207
1219
|
...(event.answer === undefined ? {} : { answer: event.answer }),
|
|
1208
1220
|
};
|
|
1209
1221
|
}
|
|
@@ -80,6 +80,15 @@ export const InteractionsIndexDoc = defineDoc({
|
|
|
80
80
|
checkpointWhen: () => false,
|
|
81
81
|
initial: () => ({ pending: {} }),
|
|
82
82
|
});
|
|
83
|
+
export const QueuedHumanInputsDoc = defineDoc({
|
|
84
|
+
kind: "sigrun.interactions.queued-human-inputs",
|
|
85
|
+
version: 1,
|
|
86
|
+
scope: "conversation",
|
|
87
|
+
checkpointWhen: () => false,
|
|
88
|
+
history: "latest",
|
|
89
|
+
fork: "current",
|
|
90
|
+
initial: () => ({ byId: {} }),
|
|
91
|
+
});
|
|
83
92
|
/** Terminal statuses: answering one of these is a no-op (first wins).
|
|
84
93
|
* Exported for the event bus (W3 Task 4): the `interaction.pending` /
|
|
85
94
|
* `interaction.settled` host-event split derives from the SAME set — one
|
|
@@ -127,6 +136,12 @@ export function validateInteraction(value) {
|
|
|
127
136
|
if (r.answeredBy !== undefined && r.answeredBy !== "human" && r.answeredBy !== "extension") {
|
|
128
137
|
return "answeredBy must be 'human' or 'extension'";
|
|
129
138
|
}
|
|
139
|
+
if (r.declinable !== undefined && r.declinable !== true) {
|
|
140
|
+
return "declinable must be true when present";
|
|
141
|
+
}
|
|
142
|
+
if (r.declinedVia !== undefined && r.declinedVia !== "button" && r.declinedVia !== "message") {
|
|
143
|
+
return "declinedVia must be 'button' or 'message'";
|
|
144
|
+
}
|
|
130
145
|
if (r.body !== undefined) {
|
|
131
146
|
// Ruling 16 (no compatibility layer): legacy-shaped bodies (objects
|
|
132
147
|
// keyed text/options/fields/diff rather than {v, root}) fail HERE,
|
|
@@ -391,6 +406,64 @@ export function applyAnswer(draft, answer, answeredBy, now, answeredByUser, opts
|
|
|
391
406
|
mutable.settledAt = now;
|
|
392
407
|
return true;
|
|
393
408
|
}
|
|
409
|
+
/**
|
|
410
|
+
* The committed decline for one record (ask-user Task 1): status
|
|
411
|
+
* `"skipped"` with `declinedVia`, `answeredBy: "human"` (declining is a
|
|
412
|
+
* human act — nothing auto-declines) and `answeredByUser` when given.
|
|
413
|
+
* Returns `"declined"` on the applied transition, `"not_pending"` when
|
|
414
|
+
* the record is already terminal (first-wins), `"not_declinable"` when
|
|
415
|
+
* the record carries no `declinable: true` flag. The draft is mutated in
|
|
416
|
+
* place — this runs inside the caller's commit.
|
|
417
|
+
*/
|
|
418
|
+
export function applyDecline(draft, via, now, user) {
|
|
419
|
+
if (draft.status !== "pending")
|
|
420
|
+
return "not_pending";
|
|
421
|
+
if (draft.declinable !== true)
|
|
422
|
+
return "not_declinable";
|
|
423
|
+
// SAFETY: the draft's declared shape is readonly; the doc family's
|
|
424
|
+
// tracked-draft write pattern mutates through a structural record view
|
|
425
|
+
// (every key below exists on the stored record by construction).
|
|
426
|
+
const mutable = draft;
|
|
427
|
+
mutable.status = "skipped";
|
|
428
|
+
mutable.settledAt = now;
|
|
429
|
+
mutable.answeredBy = "human";
|
|
430
|
+
if (user !== undefined)
|
|
431
|
+
mutable.answeredByUser = user;
|
|
432
|
+
mutable.declinedVia = via;
|
|
433
|
+
return "declined";
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* The admitted-message decline (ask-user Task 2): a human's message
|
|
437
|
+
* admitted through `submitOp` declines every PENDING declinable record in
|
|
438
|
+
* the conversation (`via: "message"`), in one commit, right after
|
|
439
|
+
* admission. Only the seam host's own kinds are in scope — the index
|
|
440
|
+
* lists every pending record, and the seed's `interactionKinds` gate
|
|
441
|
+
* already restricted what can be pending, but the draft read re-checks
|
|
442
|
+
* `declinable` so a record without the flag is never touched.
|
|
443
|
+
*
|
|
444
|
+
* Returns the callIds that transitioned. The caller (submitOp) passes
|
|
445
|
+
* `user` (the submitting principal's user, when identified); an
|
|
446
|
+
* unidentified submitter still declines, just without attribution.
|
|
447
|
+
*/
|
|
448
|
+
export async function declinePendingOnMessage(harness, conversationId, user, now, context) {
|
|
449
|
+
return await harness.commit(async (tx) => {
|
|
450
|
+
const index = await tx.doc(InteractionsIndexDoc);
|
|
451
|
+
const pending = index.pending[String(conversationId)] ?? [];
|
|
452
|
+
if (pending.length === 0)
|
|
453
|
+
return [];
|
|
454
|
+
const declined = [];
|
|
455
|
+
for (const callId of pending) {
|
|
456
|
+
const draft = await tx.doc(InteractionDoc, conversationId, callId, callId);
|
|
457
|
+
const outcome = applyDecline(draft, "message", now, user);
|
|
458
|
+
if (outcome === "declined") {
|
|
459
|
+
indexSettled(index, conversationId, callId);
|
|
460
|
+
sweepSecretDelivery(conversationId, callId);
|
|
461
|
+
declined.push(callId);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return declined;
|
|
465
|
+
}, context);
|
|
466
|
+
}
|
|
394
467
|
/**
|
|
395
468
|
* Take one interaction's secret delivery (the extension-only read path):
|
|
396
469
|
* the TRUE answer when a secret was answered and never yet read — the
|
|
@@ -400,6 +473,14 @@ export function applyAnswer(draft, answer, answeredBy, now, answeredByUser, opts
|
|
|
400
473
|
export function takeSecretDelivery(conversationId, callId) {
|
|
401
474
|
return secretDeliveries.take(conversationId, callId);
|
|
402
475
|
}
|
|
476
|
+
/**
|
|
477
|
+
* Record one interaction's true-answer delivery WITHOUT settling the
|
|
478
|
+
* record. @internal test-only (ask-user tests stage deliveries out of
|
|
479
|
+
* band); production writes go through `applyAnswer`.
|
|
480
|
+
*/
|
|
481
|
+
export function recordSecretDelivery(conversationId, callId, answer) {
|
|
482
|
+
secretDeliveries.record(conversationId, callId, answer);
|
|
483
|
+
}
|
|
403
484
|
/**
|
|
404
485
|
* Drop one interaction's unread secret delivery without reading it (the
|
|
405
486
|
* settle/expiry sweep: terminal transitions the extension never polled
|