@plurnk/plurnk-service 0.2.0 → 0.4.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/SPEC.md +98 -112
- package/dist/core/ChannelWrite.d.ts +10 -0
- package/dist/core/ChannelWrite.d.ts.map +1 -1
- package/dist/core/ChannelWrite.js.map +1 -1
- package/dist/core/Engine.d.ts +10 -2
- package/dist/core/Engine.d.ts.map +1 -1
- package/dist/core/Engine.js +410 -113
- package/dist/core/Engine.js.map +1 -1
- package/dist/core/ProviderRegistry.d.ts +1 -1
- package/dist/core/ProviderRegistry.d.ts.map +1 -1
- package/dist/core/ProviderRegistry.js.map +1 -1
- package/dist/core/line-marker.d.ts +23 -0
- package/dist/core/line-marker.d.ts.map +1 -0
- package/dist/core/line-marker.js +321 -0
- package/dist/core/line-marker.js.map +1 -0
- package/dist/core/matcher.d.ts +14 -0
- package/dist/core/matcher.d.ts.map +1 -0
- package/dist/core/matcher.js +195 -0
- package/dist/core/matcher.js.map +1 -0
- package/dist/core/mimetype-binary.d.ts +6 -0
- package/dist/core/mimetype-binary.d.ts.map +1 -0
- package/dist/core/mimetype-binary.js +82 -0
- package/dist/core/mimetype-binary.js.map +1 -0
- package/dist/core/packet-wire.d.ts.map +1 -1
- package/dist/core/packet-wire.js +174 -33
- package/dist/core/packet-wire.js.map +1 -1
- package/dist/core/path-mimetype.d.ts +3 -0
- package/dist/core/path-mimetype.d.ts.map +1 -0
- package/dist/core/path-mimetype.js +47 -0
- package/dist/core/path-mimetype.js.map +1 -0
- package/dist/core/scheme-types.d.ts +5 -0
- package/dist/core/scheme-types.d.ts.map +1 -1
- package/dist/core/scheme-types.js.map +1 -1
- package/dist/providers/Mock.d.ts +2 -2
- package/dist/providers/Mock.d.ts.map +1 -1
- package/dist/providers/Mock.js.map +1 -1
- package/dist/schemes/Exec.d.ts +30 -1
- package/dist/schemes/Exec.d.ts.map +1 -1
- package/dist/schemes/Exec.js +229 -6
- package/dist/schemes/Exec.js.map +1 -1
- package/dist/schemes/File.d.ts +8 -4
- package/dist/schemes/File.d.ts.map +1 -1
- package/dist/schemes/File.js +184 -68
- package/dist/schemes/File.js.map +1 -1
- package/dist/schemes/Known.js +1 -1
- package/dist/schemes/Known.js.map +1 -1
- package/dist/schemes/Log.d.ts +9 -1
- package/dist/schemes/Log.d.ts.map +1 -1
- package/dist/schemes/Log.js +120 -19
- package/dist/schemes/Log.js.map +1 -1
- package/dist/schemes/Plurnk.d.ts +15 -1
- package/dist/schemes/Plurnk.d.ts.map +1 -1
- package/dist/schemes/Plurnk.js +50 -8
- package/dist/schemes/Plurnk.js.map +1 -1
- package/dist/schemes/Skill.js +1 -1
- package/dist/schemes/Skill.js.map +1 -1
- package/dist/schemes/Unknown.js +1 -1
- package/dist/schemes/Unknown.js.map +1 -1
- package/dist/schemes/_entry-crud.d.ts +5 -3
- package/dist/schemes/_entry-crud.d.ts.map +1 -1
- package/dist/schemes/_entry-crud.js +1 -0
- package/dist/schemes/_entry-crud.js.map +1 -1
- package/dist/schemes/_entry-find.d.ts.map +1 -1
- package/dist/schemes/_entry-find.js +64 -15
- package/dist/schemes/_entry-find.js.map +1 -1
- package/dist/schemes/_entry-ops.d.ts +3 -0
- package/dist/schemes/_entry-ops.d.ts.map +1 -1
- package/dist/schemes/_entry-ops.js +269 -72
- package/dist/schemes/_entry-ops.js.map +1 -1
- package/dist/schemes/_entry-send.d.ts.map +1 -1
- package/dist/schemes/_entry-send.js +14 -7
- package/dist/schemes/_entry-send.js.map +1 -1
- package/dist/server/ClientConnection.d.ts +3 -2
- package/dist/server/ClientConnection.d.ts.map +1 -1
- package/dist/server/ClientConnection.js +10 -2
- package/dist/server/ClientConnection.js.map +1 -1
- package/dist/server/Daemon.d.ts +40 -1
- package/dist/server/Daemon.d.ts.map +1 -1
- package/dist/server/Daemon.js +319 -1
- package/dist/server/Daemon.js.map +1 -1
- package/dist/server/MethodRegistry.d.ts +29 -0
- package/dist/server/MethodRegistry.d.ts.map +1 -1
- package/dist/server/MethodRegistry.js.map +1 -1
- package/dist/server/dsl.d.ts +2 -2
- package/dist/server/dsl.d.ts.map +1 -1
- package/dist/server/dsl.js +10 -10
- package/dist/server/dsl.js.map +1 -1
- package/dist/server/envelope.d.ts +2 -1
- package/dist/server/envelope.d.ts.map +1 -1
- package/dist/server/envelope.js +11 -8
- package/dist/server/envelope.js.map +1 -1
- package/dist/server/logEntry.d.ts +9 -9
- package/dist/server/logEntry.d.ts.map +1 -1
- package/dist/server/logEntry.js +10 -10
- package/dist/server/logEntry.js.map +1 -1
- package/dist/server/methods/_dispatchAsClient.d.ts.map +1 -1
- package/dist/server/methods/_dispatchAsClient.js +7 -2
- package/dist/server/methods/_dispatchAsClient.js.map +1 -1
- package/dist/server/methods/entry_read.js +5 -5
- package/dist/server/methods/entry_read.js.map +1 -1
- package/dist/server/methods/loop_cancel.d.ts +3 -0
- package/dist/server/methods/loop_cancel.d.ts.map +1 -0
- package/dist/server/methods/loop_cancel.js +27 -0
- package/dist/server/methods/loop_cancel.js.map +1 -0
- package/dist/server/methods/loop_run.d.ts.map +1 -1
- package/dist/server/methods/loop_run.js +53 -49
- package/dist/server/methods/loop_run.js.map +1 -1
- package/dist/server/methods/op_edit.js +3 -3
- package/dist/server/methods/op_edit.js.map +1 -1
- package/dist/server/methods/op_hide.js +3 -3
- package/dist/server/methods/op_hide.js.map +1 -1
- package/dist/server/methods/op_read.js +3 -3
- package/dist/server/methods/op_read.js.map +1 -1
- package/dist/server/methods/op_show.js +3 -3
- package/dist/server/methods/op_show.js.map +1 -1
- package/migrations/001_schema.sql +324 -0
- package/package.json +19 -12
- package/migrations/001_sessions.sql +0 -11
- package/migrations/002_runs.sql +0 -16
- package/migrations/003_loops.sql +0 -12
- package/migrations/004_turns.sql +0 -18
- package/migrations/005_entries.sql +0 -44
- package/migrations/006_log_entries.sql +0 -51
- package/migrations/007_visibility.sql +0 -10
- package/migrations/008_schemes_providers.sql +0 -25
- package/migrations/009_cost_rollups.sql +0 -44
- package/migrations/010_subscriptions.sql +0 -36
- package/migrations/011_turn_call_metadata.sql +0 -7
- package/migrations/012_proposal_lifecycle.sql +0 -22
- package/migrations/013_log_entries_lifecycle_columns.sql +0 -22
- package/migrations/014_loop_flags.sql +0 -9
- package/migrations/015_sessions_project_root.sql +0 -6
- package/migrations/016_persona_columns.sql +0 -11
package/dist/core/Engine.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { PlurnkParser } from "@plurnk/plurnk-grammar";
|
|
2
2
|
import { Mimetypes, emptyRegistry } from "@plurnk/plurnk-mimetypes";
|
|
3
|
+
import { writeEntry } from "../schemes/_entry-crud.js";
|
|
3
4
|
import { DEFAULT_LOOP_FLAGS } from "./scheme-types.js";
|
|
5
|
+
import { sliceLinesRaw } from "./line-marker.js";
|
|
6
|
+
import { isBinaryMimetype } from "./mimetype-binary.js";
|
|
4
7
|
// Plain JS module shared with bin/digest.js so wire projection and
|
|
5
8
|
// digest projection are structurally one function. tsconfig.build.json
|
|
6
9
|
// has allowJs:true so this gets copied through to dist/.
|
|
@@ -67,17 +70,57 @@ const readPositiveInt = (envVar, fallback) => {
|
|
|
67
70
|
return fallback;
|
|
68
71
|
return n;
|
|
69
72
|
};
|
|
70
|
-
// Per-op fingerprint: op verb + target URI
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
73
|
+
// Per-op fingerprint: op verb + target URI, plus an op-specific discriminator
|
|
74
|
+
// where the activity isn't fully captured by target alone:
|
|
75
|
+
// - EDIT/COPY/MOVE: body excluded — re-writing the same target with varied
|
|
76
|
+
// content IS cycling (the model is producing different versions of the
|
|
77
|
+
// same artifact instead of progressing).
|
|
78
|
+
// - FIND/READ/SHOW/HIDE: body IS the search/selection pattern; varied
|
|
79
|
+
// matchers on the same target ARE different activities (the model is
|
|
80
|
+
// exploring different queries, not repeating one).
|
|
74
81
|
const fingerprintOp = (stmt) => {
|
|
75
|
-
const path = stmt.
|
|
76
|
-
|
|
82
|
+
const path = stmt.target;
|
|
83
|
+
const matcherDiscriminator = () => {
|
|
84
|
+
// For matcher-bearing ops, the body's `raw` (matcher source) plus
|
|
85
|
+
// any lineMarker forms the activity discriminator.
|
|
86
|
+
const parts = [];
|
|
87
|
+
const body = stmt.body;
|
|
88
|
+
if (body !== null && typeof body === "object" && typeof body.raw === "string") {
|
|
89
|
+
parts.push(`body:${body.raw.slice(0, 64)}`);
|
|
90
|
+
}
|
|
91
|
+
const lm = stmt.lineMarker;
|
|
92
|
+
if (lm !== null && lm !== undefined)
|
|
93
|
+
parts.push(`L:${lm.first},${lm.last ?? ""}`);
|
|
94
|
+
return parts.length > 0 ? `|${parts.join("|")}` : "";
|
|
95
|
+
};
|
|
96
|
+
if (path === null) {
|
|
97
|
+
// Path-less ops need an activity-defining discriminator other
|
|
98
|
+
// than `target`. Picked per op so the cycle detector reflects
|
|
99
|
+
// intent rather than syntax:
|
|
100
|
+
// - EXEC: the command body IS the activity. Without a body
|
|
101
|
+
// digest, varied shell commands (find / ls / wc) collapse to
|
|
102
|
+
// one fingerprint and the detector mislabels exploration
|
|
103
|
+
// as a loop.
|
|
104
|
+
// - SEND: the status code (signal) IS the activity. Different
|
|
105
|
+
// SEND[X] are different intentions; same SEND[X] with
|
|
106
|
+
// different message bodies is the same termination signal.
|
|
107
|
+
if (stmt.op === "EXEC") {
|
|
108
|
+
const body = typeof stmt.body === "string" ? stmt.body : "";
|
|
109
|
+
return `EXEC|(no-path)${body.length > 0 ? `|body:${body.slice(0, 64)}` : ""}`;
|
|
110
|
+
}
|
|
111
|
+
if (stmt.op === "SEND") {
|
|
112
|
+
const signal = typeof stmt.signal === "number" ? stmt.signal : "";
|
|
113
|
+
return `SEND|(no-path)|signal:${signal}`;
|
|
114
|
+
}
|
|
77
115
|
return `${stmt.op}|(no-path)`;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
116
|
+
}
|
|
117
|
+
const base = path.kind === "url"
|
|
118
|
+
? `${stmt.op}|${path.scheme}://${path.pathname}`
|
|
119
|
+
: `${stmt.op}|local:${path.raw}`;
|
|
120
|
+
if (stmt.op === "FIND" || stmt.op === "READ" || stmt.op === "SHOW" || stmt.op === "HIDE") {
|
|
121
|
+
return `${base}${matcherDiscriminator()}`;
|
|
122
|
+
}
|
|
123
|
+
return base;
|
|
81
124
|
};
|
|
82
125
|
// Per-turn fingerprint: sorted set of per-op fingerprints, joined. Order
|
|
83
126
|
// within a turn doesn't matter — we want the SET of activities.
|
|
@@ -135,9 +178,19 @@ export default class Engine {
|
|
|
135
178
|
// event emitter — no priority, no veto chain at this layer; filter
|
|
136
179
|
// chains come later if a real consumer needs them.
|
|
137
180
|
#proposalPendingListeners = [];
|
|
138
|
-
|
|
181
|
+
// Per-loop AbortController for cancellation propagation into scheme
|
|
182
|
+
// ctx.signal. runLoop creates one at entry, cleans up at end. Engine
|
|
183
|
+
// cancellation paths (strikes, max_turns, external) abort it.
|
|
184
|
+
// Streaming schemes (exec) chain their per-spawn controllers off
|
|
185
|
+
// ctx.signal so cancelled loops tear down their background spawns.
|
|
186
|
+
#loopAborts = new Map();
|
|
187
|
+
#streamEventNotify;
|
|
188
|
+
#wakeRunNotify;
|
|
189
|
+
constructor({ db, schemes, mimetypes, streamEventNotify, wakeRunNotify }) {
|
|
139
190
|
this.#db = db;
|
|
140
191
|
this.#schemes = schemes;
|
|
192
|
+
this.#streamEventNotify = streamEventNotify;
|
|
193
|
+
this.#wakeRunNotify = wakeRunNotify;
|
|
141
194
|
// Default to empty discovery — standalone Engine construction (in
|
|
142
195
|
// tests) gets no handlers, and content flows through the framework's
|
|
143
196
|
// raw-content fitContent fallback. Daemon-managed Engine receives a
|
|
@@ -164,7 +217,29 @@ export default class Engine {
|
|
|
164
217
|
async runLoop({ provider, messages, persona = "", sessionId, runId, loopId, maxTurns = 50, maxStrikes = readMaxStrikes(), minCycles = readPositiveInt("PLURNK_MIN_CYCLES", DEFAULT_MIN_CYCLES), maxCyclePeriod = readPositiveInt("PLURNK_MAX_CYCLE_PERIOD", DEFAULT_MAX_CYCLE_PERIOD), origin = "model", signal, onDispatch, }) {
|
|
165
218
|
const turnIds = [];
|
|
166
219
|
const suddenDeathThreshold = maxTurns - maxStrikes;
|
|
167
|
-
|
|
220
|
+
// Per-loop AbortController for scheme-side cancellation propagation.
|
|
221
|
+
// Chained from the caller's `signal` so an external abort cascades.
|
|
222
|
+
const loopAbort = new AbortController();
|
|
223
|
+
if (signal !== undefined) {
|
|
224
|
+
if (signal.aborted)
|
|
225
|
+
loopAbort.abort(signal.reason);
|
|
226
|
+
else
|
|
227
|
+
signal.addEventListener("abort", () => loopAbort.abort(signal.reason), { once: true });
|
|
228
|
+
}
|
|
229
|
+
this.#loopAborts.set(loopId, loopAbort);
|
|
230
|
+
// Cleanup splits by termination kind:
|
|
231
|
+
// - "graceful" (loop emitted SEND[2xx]): in-flight streaming-scheme
|
|
232
|
+
// spawns are ALLOWED to outlive the loop. They complete naturally,
|
|
233
|
+
// write final channel state, and wake-on-completion (E.4) opens a
|
|
234
|
+
// fresh loop in the same run if the model needs to react.
|
|
235
|
+
// - "forceful" (max_turns, strike_threshold, external cancel,
|
|
236
|
+
// non-2xx loop status): fire the loop-level abort so spawns
|
|
237
|
+
// tear down immediately.
|
|
238
|
+
const cleanup = (kind, reason) => {
|
|
239
|
+
if (kind === "forceful" && !loopAbort.signal.aborted) {
|
|
240
|
+
loopAbort.abort(reason ?? "loop_forceful_termination");
|
|
241
|
+
}
|
|
242
|
+
this.#loopAborts.delete(loopId);
|
|
168
243
|
this.#strikeState.delete(loopId);
|
|
169
244
|
this.#telemetryBuffer.delete(loopId);
|
|
170
245
|
};
|
|
@@ -174,12 +249,15 @@ export default class Engine {
|
|
|
174
249
|
if (row === undefined)
|
|
175
250
|
throw new Error(`Engine.runLoop: loop ${loopId} not found`);
|
|
176
251
|
if (row.status !== 102) {
|
|
177
|
-
|
|
252
|
+
// Status 2xx = graceful (model said done); 4xx/5xx = forceful
|
|
253
|
+
// (external cancel or upstream failure). The threshold splits
|
|
254
|
+
// at 400 to match HTTP success/error semantics.
|
|
255
|
+
cleanup(row.status < 400 ? "graceful" : "forceful", `loop_terminal_${row.status}`);
|
|
178
256
|
return { turnIds, finalStatus: row.status, hitMaxTurns: false, reason: "external" };
|
|
179
257
|
}
|
|
180
258
|
if (turnIds.length >= maxTurns) {
|
|
181
259
|
await this.#db.engine_loop_cancel.run({ loop_id: loopId });
|
|
182
|
-
cleanup();
|
|
260
|
+
cleanup("forceful", "max_turns");
|
|
183
261
|
return { turnIds, finalStatus: 499, hitMaxTurns: true, reason: "max_turns" };
|
|
184
262
|
}
|
|
185
263
|
const turn = await this.runTurn({
|
|
@@ -199,7 +277,7 @@ export default class Engine {
|
|
|
199
277
|
kind: "cycle",
|
|
200
278
|
period: cycle.period,
|
|
201
279
|
cycles: cycle.cycles,
|
|
202
|
-
message:
|
|
280
|
+
message: "Loop detected",
|
|
203
281
|
});
|
|
204
282
|
}
|
|
205
283
|
this.#strikeState.set(loopId, state);
|
|
@@ -214,15 +292,17 @@ export default class Engine {
|
|
|
214
292
|
const struck = noOps || recordedFailed || state.turnErrors > 0;
|
|
215
293
|
if (struck) {
|
|
216
294
|
state.streak++;
|
|
295
|
+
const reason = noOps ? "no_ops" : recordedFailed ? "recorded_failure" : "rail";
|
|
217
296
|
this.#pushTelemetry(loopId, {
|
|
218
297
|
kind: "strike",
|
|
219
298
|
streak: state.streak,
|
|
220
299
|
maxStrikes,
|
|
221
|
-
reason
|
|
300
|
+
reason,
|
|
301
|
+
message: `strike ${state.streak}/${maxStrikes} (${reason}); ${maxStrikes - state.streak} before abandonment`,
|
|
222
302
|
});
|
|
223
303
|
if (state.streak >= maxStrikes) {
|
|
224
304
|
await this.#db.engine_loop_cancel.run({ loop_id: loopId });
|
|
225
|
-
cleanup();
|
|
305
|
+
cleanup("forceful", "strike_threshold");
|
|
226
306
|
return { turnIds, finalStatus: 499, hitMaxTurns: false, reason: "strike_threshold" };
|
|
227
307
|
}
|
|
228
308
|
}
|
|
@@ -238,19 +318,70 @@ export default class Engine {
|
|
|
238
318
|
if (turnIds.length >= suddenDeathThreshold && turnIds.length < maxTurns) {
|
|
239
319
|
this.#pushTelemetry(loopId, {
|
|
240
320
|
kind: "sudden_death",
|
|
241
|
-
message:
|
|
321
|
+
message: `${turnIds.length} of ${maxTurns}`,
|
|
242
322
|
remaining: maxTurns - turnIds.length,
|
|
243
323
|
});
|
|
244
324
|
}
|
|
245
325
|
}
|
|
246
326
|
}
|
|
247
327
|
async runTurn({ provider, messages, persona = "", sessionId, runId, loopId, origin = "model", signal, onDispatch, turnNumber = 1, maxTurns = 50, }) {
|
|
248
|
-
//
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
//
|
|
328
|
+
// === Turn-as-container model ===
|
|
329
|
+
//
|
|
330
|
+
// Turn rows are created at runTurn OPEN (status=102, placeholder
|
|
331
|
+
// packet) so things can be written into the turn before the model
|
|
332
|
+
// is called: the user prompt on turn 1; later, system signals or
|
|
333
|
+
// injected telemetry events on any turn. The turn is CLOSED at
|
|
334
|
+
// the end with the final packet + status + usage stats.
|
|
335
|
+
//
|
|
336
|
+
// sequence is "ordinal of stuff in this turn." Pre-model
|
|
337
|
+
// writes consume low indices; model ops continue from there.
|
|
338
|
+
const seqRow = await this.#db.engine_next_turn_sequence.get({ loop_id: loopId });
|
|
339
|
+
const seq = seqRow.next;
|
|
340
|
+
const openRow = await this.#db.engine_open_turn.get({
|
|
341
|
+
loop_id: loopId, sequence: seq,
|
|
342
|
+
});
|
|
343
|
+
if (openRow === undefined)
|
|
344
|
+
throw new Error("Engine.runTurn: turn open returned no row");
|
|
345
|
+
const turnId = openRow.id;
|
|
346
|
+
// Pre-model writes. Each turn opens with a system-origin EDIT
|
|
347
|
+
// against `plurnk://prompt/<loop_id>/<seq>` IF there's a prompt
|
|
348
|
+
// for THIS turn the model hasn't seen yet:
|
|
349
|
+
// - Turn 1: loop.prompt is the initial user prompt.
|
|
350
|
+
// - Turn N>1: only if Engine.inject (or wake-on-completion via
|
|
351
|
+
// daemon.inject) wrote a prompt entry for this turn slot
|
|
352
|
+
// between turn N-1 and N. Inject writes directly to entries;
|
|
353
|
+
// we DON'T re-foist here for N>1.
|
|
354
|
+
// The log records the EDIT for forensics. Model ops dispatch
|
|
355
|
+
// from sequence=2 onward on prompt-foisted turns; 1 onward
|
|
356
|
+
// otherwise.
|
|
357
|
+
let nextActionIndex = 1;
|
|
358
|
+
if (seq === 1) {
|
|
359
|
+
const promptRow = await this.#db.engine_get_loop_prompt.get({ loop_id: loopId });
|
|
360
|
+
if (promptRow !== undefined && typeof promptRow.prompt === "string" && promptRow.prompt.length > 0) {
|
|
361
|
+
const promptPath = {
|
|
362
|
+
kind: "url", raw: `plurnk://prompt/${loopId}/${seq}`,
|
|
363
|
+
scheme: "plurnk", username: null, password: null,
|
|
364
|
+
hostname: null, port: null,
|
|
365
|
+
pathname: `prompt/${loopId}/${seq}`, params: {}, fragment: null,
|
|
366
|
+
};
|
|
367
|
+
const promptStmt = {
|
|
368
|
+
op: "EDIT", suffix: "", signal: null,
|
|
369
|
+
target: promptPath, lineMarker: null,
|
|
370
|
+
body: promptRow.prompt, position: { line: 1, column: 1 },
|
|
371
|
+
};
|
|
372
|
+
await this.dispatch({
|
|
373
|
+
statement: promptStmt, sessionId, runId, loopId, turnId,
|
|
374
|
+
sequence: nextActionIndex, origin: "system",
|
|
375
|
+
});
|
|
376
|
+
nextActionIndex++;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
// Build the spec'd packet (Packet.json) request half. #buildLog
|
|
380
|
+
// queries log_entries scoped to the run — the prompt entry just
|
|
381
|
+
// written (if turn 1) is part of that query result.
|
|
252
382
|
const requestPacket = await this.#buildRequestPacket({
|
|
253
|
-
initialMessages: messages, persona, runId, loopId,
|
|
383
|
+
initialMessages: messages, persona, runId, loopId,
|
|
384
|
+
turnNumber, currentTurnSeq: seq, maxTurns, provider,
|
|
254
385
|
});
|
|
255
386
|
const modelMessages = this.#packetToWireMessages(requestPacket);
|
|
256
387
|
const response = await provider.generate({ messages: modelMessages, signal });
|
|
@@ -258,7 +389,15 @@ export default class Engine {
|
|
|
258
389
|
// parsed ops) → packet.assistant per Packet.json §assistant;
|
|
259
390
|
// call-metadata (usage, finishReason, model) → Turn columns per
|
|
260
391
|
// Turn.json. Mixing the two on packet.assistant was the wrong layer.
|
|
261
|
-
const { packetAssistant, callMetadata } = this.#splitResponse(response);
|
|
392
|
+
const { packetAssistant, callMetadata, parseErrors } = this.#splitResponse(response);
|
|
393
|
+
// Surface parse errors to the model's NEXT packet so it can self-
|
|
394
|
+
// correct. Without this, malformed emissions (e.g. a READ matcher
|
|
395
|
+
// body starting with `//` being interpreted as xpath) silently
|
|
396
|
+
// drop, the model sees zero ops dispatched, strike-rail fires,
|
|
397
|
+
// model has no feedback on WHY its emission didn't take effect.
|
|
398
|
+
for (const message of parseErrors ?? []) {
|
|
399
|
+
this.#pushTelemetry(loopId, { kind: "parse_error", message });
|
|
400
|
+
}
|
|
262
401
|
const opsCount = packetAssistant.ops.length;
|
|
263
402
|
const sendOp = packetAssistant.ops.findLast((op) => op.op === "SEND" && typeof op.signal === "number");
|
|
264
403
|
// Rail #41 (revised): the per-turn requirement is "emit at least one
|
|
@@ -267,16 +406,11 @@ export default class Engine {
|
|
|
267
406
|
const turnStatus = sendOp !== undefined
|
|
268
407
|
? sendOp.signal
|
|
269
408
|
: opsCount === 0 ? TURN_STATUS_NO_OPS : TURN_STATUS_IMPLICIT_CONTINUE;
|
|
270
|
-
|
|
271
|
-
const seq = seqRow.next;
|
|
272
|
-
// Complete the spec'd packet by adding the response section.
|
|
273
|
-
// requestPacket already has system + user matching what was sent
|
|
274
|
-
// to the LLM (one source of truth across wire payload and storage).
|
|
409
|
+
// Close the turn with the final packet, status, and usage stats.
|
|
275
410
|
const packet = this.#completePacket(requestPacket, packetAssistant, response.assistantRaw, provider);
|
|
276
411
|
const { usage, finishReason, model } = callMetadata;
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
sequence: seq,
|
|
412
|
+
await this.#db.engine_close_turn.run({
|
|
413
|
+
id: turnId,
|
|
280
414
|
status: turnStatus,
|
|
281
415
|
packet: JSON.stringify(packet),
|
|
282
416
|
usage_prompt: usage.prompt,
|
|
@@ -286,13 +420,14 @@ export default class Engine {
|
|
|
286
420
|
finish_reason: finishReason,
|
|
287
421
|
model,
|
|
288
422
|
});
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
const turnId = turnRow.id;
|
|
423
|
+
// Dispatch model ops starting at nextActionIndex (continues the
|
|
424
|
+
// turn's running counter after any pre-model writes).
|
|
292
425
|
const statuses = [];
|
|
293
|
-
for (const [
|
|
426
|
+
for (const [i, statement] of packetAssistant.ops.entries()) {
|
|
294
427
|
const result = await this.dispatch({
|
|
295
|
-
statement, sessionId, runId, loopId, turnId,
|
|
428
|
+
statement, sessionId, runId, loopId, turnId,
|
|
429
|
+
sequence: nextActionIndex + i,
|
|
430
|
+
origin, onDispatch,
|
|
296
431
|
});
|
|
297
432
|
statuses.push(result.status);
|
|
298
433
|
}
|
|
@@ -323,6 +458,7 @@ export default class Engine {
|
|
|
323
458
|
const preParsedOps = assistant.ops;
|
|
324
459
|
const ops = [];
|
|
325
460
|
const textFragments = [];
|
|
461
|
+
const parseErrors = [];
|
|
326
462
|
if (preParsedOps !== undefined) {
|
|
327
463
|
ops.push(...preParsedOps);
|
|
328
464
|
}
|
|
@@ -336,6 +472,11 @@ export default class Engine {
|
|
|
336
472
|
if (trimmed.length > 0)
|
|
337
473
|
textFragments.push(trimmed);
|
|
338
474
|
}
|
|
475
|
+
else if (item.kind === "error") {
|
|
476
|
+
const err = item.error;
|
|
477
|
+
const msg = (err && typeof err.message === "string") ? err.message : "parse error";
|
|
478
|
+
parseErrors.push(msg);
|
|
479
|
+
}
|
|
339
480
|
}
|
|
340
481
|
}
|
|
341
482
|
const wireReasoning = assistant.reasoning ?? "";
|
|
@@ -345,6 +486,7 @@ export default class Engine {
|
|
|
345
486
|
return {
|
|
346
487
|
packetAssistant: { content: assistant.content, ops, reasoning },
|
|
347
488
|
callMetadata: { usage: assistant.usage, finishReason: assistant.finishReason, model: assistant.model },
|
|
489
|
+
parseErrors,
|
|
348
490
|
};
|
|
349
491
|
}
|
|
350
492
|
// Assemble the request half of the spec'd packet (Packet.json §system
|
|
@@ -354,18 +496,26 @@ export default class Engine {
|
|
|
354
496
|
// Per Packet.json: user.prompt is "Copy of loop.prompt — never null on
|
|
355
497
|
// a continuation turn"; the turn-N-of-M continuation marker rides on
|
|
356
498
|
// user.system_requirements (per-turn rules), NOT a mutated prompt.
|
|
357
|
-
async #buildRequestPacket({ initialMessages, persona: defaultPersona, runId, loopId, turnNumber, maxTurns, provider, }) {
|
|
499
|
+
async #buildRequestPacket({ initialMessages, persona: defaultPersona, runId, loopId, turnNumber, currentTurnSeq, maxTurns, provider, }) {
|
|
358
500
|
const byRole = (role) => initialMessages.filter((m) => m.role === role).map((m) => m.content).join("\n\n");
|
|
359
501
|
const system_definition = byRole("system");
|
|
360
|
-
|
|
502
|
+
// user.prompt sources from the loop's most recent prompt entry first
|
|
503
|
+
// (plurnk://prompt/<loop_id>/<N> for the highest N written to date).
|
|
504
|
+
// This is what inject + the turn-1 foist write into. Falls back to
|
|
505
|
+
// the runLoop caller's messages.user for tests that bypass the
|
|
506
|
+
// foist mechanism entirely.
|
|
507
|
+
const latestPromptRow = await this.#db.drain_get_latest_prompt_body_for_loop.get({ pattern: `prompt/${loopId}/%` });
|
|
508
|
+
const prompt = (latestPromptRow !== undefined && typeof latestPromptRow.content === "string" && latestPromptRow.content.length > 0)
|
|
509
|
+
? latestPromptRow.content
|
|
510
|
+
: byRole("user");
|
|
361
511
|
// Resolve persona cascade: loops.persona > runs.persona >
|
|
362
512
|
// sessions.persona > caller-supplied default. SQL coalesces in one
|
|
363
513
|
// query; null result means no DB override exists, use the default.
|
|
364
514
|
const row = await this.#db.engine_resolve_persona.get({ loop_id: loopId });
|
|
365
515
|
const persona = (row?.persona !== undefined && row?.persona !== null) ? row.persona : defaultPersona;
|
|
366
|
-
const index = await this.#buildIndex(runId);
|
|
367
|
-
const log = await this.#buildLog(
|
|
368
|
-
const telemetryErrors = await this.#buildTelemetryErrors(loopId);
|
|
516
|
+
const index = await this.#buildIndex(runId, loopId);
|
|
517
|
+
const log = await this.#buildLog(runId);
|
|
518
|
+
const telemetryErrors = await this.#buildTelemetryErrors(loopId, currentTurnSeq);
|
|
369
519
|
// Rummy AgentLoop.js #buildContinuationPrompt: literally
|
|
370
520
|
// `Turn ${turn}/${maxTurns}`. That's the whole string. The model
|
|
371
521
|
// can read the action log to see what it already did; it does
|
|
@@ -455,16 +605,16 @@ export default class Engine {
|
|
|
455
605
|
// 1. Previous-turn action-bound failures (status_rx >= 400 on log_entries).
|
|
456
606
|
// 2. Engine-buffered actionless failures (no_send, parse, watchdog, rails).
|
|
457
607
|
// Buffer drains on read — each error appears in exactly one packet.
|
|
458
|
-
async #buildTelemetryErrors(loopId) {
|
|
459
|
-
const rows = await this.#db.engine_render_telemetry_errors.all({ loop_id: loopId });
|
|
608
|
+
async #buildTelemetryErrors(loopId, currentTurnSeq) {
|
|
609
|
+
const rows = await this.#db.engine_render_telemetry_errors.all({ loop_id: loopId, current_turn_seq: currentTurnSeq });
|
|
460
610
|
const actionFailures = rows.map((r) => {
|
|
461
|
-
const target = r.
|
|
462
|
-
? `${r.
|
|
463
|
-
: (r.
|
|
611
|
+
const target = r.scheme !== null
|
|
612
|
+
? `${r.scheme}://${r.pathname ?? ""}`
|
|
613
|
+
: (r.pathname ?? null);
|
|
464
614
|
const parsedRx = r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx;
|
|
465
615
|
return {
|
|
466
616
|
kind: "action_failure",
|
|
467
|
-
coordinate: `${r.loop_seq}/${r.turn_seq}/${r.
|
|
617
|
+
coordinate: `${r.loop_seq}/${r.turn_seq}/${r.sequence}`,
|
|
468
618
|
op: r.op,
|
|
469
619
|
target,
|
|
470
620
|
status: r.status_rx,
|
|
@@ -478,60 +628,64 @@ export default class Engine {
|
|
|
478
628
|
// SPEC §15 packet.system.log — chronological action-entries for the loop.
|
|
479
629
|
// Snapshot is taken at packet build (pre-dispatch this turn), so it
|
|
480
630
|
// reflects "what has happened before this turn." Each row carries a
|
|
481
|
-
// log://<loop_seq>/<turn_seq>/<
|
|
482
|
-
async #buildLog(
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
631
|
+
// log://<loop_seq>/<turn_seq>/<sequence> coordinate the model can READ.
|
|
632
|
+
async #buildLog(runId) {
|
|
633
|
+
// SPEC §0.6: runs own log entries — log is the run's history,
|
|
634
|
+
// not the loop's. Span all loops in the run so the model sees
|
|
635
|
+
// earlier loops' work as conversational memory.
|
|
636
|
+
//
|
|
637
|
+
// User prompts are first-class log entries: runTurn writes a
|
|
638
|
+
// client-origin SEND[200] row at sequence=0 of each new
|
|
639
|
+
// turn-1. Prompts thus surface naturally in this query — no
|
|
640
|
+
// synthetic / shim layer.
|
|
641
|
+
const rows = await this.#db.engine_render_log.all({ run_id: runId });
|
|
642
|
+
return rows.map((r) => ({
|
|
643
|
+
coordinate: `${r.loop_seq}/${r.turn_seq}/${r.sequence}`,
|
|
486
644
|
origin: r.origin,
|
|
487
645
|
op: r.op,
|
|
488
646
|
suffix: r.suffix,
|
|
489
647
|
signal: r.signal === null ? null : JSON.parse(r.signal),
|
|
490
648
|
target: {
|
|
491
|
-
scheme: r.
|
|
492
|
-
username: r.
|
|
493
|
-
hostname: r.
|
|
494
|
-
pathname: r.
|
|
495
|
-
params: r.
|
|
496
|
-
fragment: r.
|
|
649
|
+
scheme: r.scheme,
|
|
650
|
+
username: r.username, password: r.password,
|
|
651
|
+
hostname: r.hostname, port: r.port,
|
|
652
|
+
pathname: r.pathname,
|
|
653
|
+
params: r.params === null ? null : JSON.parse(r.params),
|
|
654
|
+
fragment: r.fragment,
|
|
497
655
|
},
|
|
498
656
|
status: r.status_rx,
|
|
499
657
|
rx: r.mimetype_rx === "application/json" ? JSON.parse(r.rx) : r.rx,
|
|
500
658
|
mimetype_rx: r.mimetype_rx,
|
|
659
|
+
tx: r.mimetype_tx === "application/json" ? JSON.parse(r.tx) : r.tx,
|
|
660
|
+
mimetype_tx: r.mimetype_tx,
|
|
501
661
|
}));
|
|
502
|
-
// Synthetic prompt entry — SHIM per AGENTS.md §Open. Prepends a
|
|
503
|
-
// "PROMPT" entry sourced from loops.prompt so `# Plurnk System Log`
|
|
504
|
-
// is never empty on turn 1. NOT URI-addressable yet (no
|
|
505
|
-
// corresponding row in log_entries / no scheme handler); real fix
|
|
506
|
-
// logs the prompt as a first-class entry the model can READ.
|
|
507
|
-
const loopRow = await this.#db.engine_get_loop_prompt.get({ loop_id: loopId });
|
|
508
|
-
if (loopRow !== undefined && typeof loopRow.prompt === "string" && loopRow.prompt.length > 0) {
|
|
509
|
-
entries.unshift({
|
|
510
|
-
coordinate: `${loopRow.sequence}/0/0`,
|
|
511
|
-
origin: "client",
|
|
512
|
-
op: "PROMPT",
|
|
513
|
-
suffix: "",
|
|
514
|
-
signal: null,
|
|
515
|
-
target: {
|
|
516
|
-
scheme: null, username: null, password: null,
|
|
517
|
-
hostname: null, port: null, pathname: null,
|
|
518
|
-
params: null, fragment: null,
|
|
519
|
-
},
|
|
520
|
-
status: 200,
|
|
521
|
-
rx: loopRow.prompt,
|
|
522
|
-
mimetype_rx: "text/plain",
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
return entries;
|
|
526
662
|
}
|
|
527
|
-
async #buildIndex(runId) {
|
|
663
|
+
async #buildIndex(runId, currentLoopId) {
|
|
528
664
|
const rows = await this.#db.engine_render_index.all({ run_id: runId });
|
|
529
665
|
const tagsStmt = this.#db.engine_entry_tags;
|
|
666
|
+
// Foist the CURRENT loop's prompt entries out of the index render —
|
|
667
|
+
// their bodies are materialized in packet.user.prompt instead. With
|
|
668
|
+
// multi-turn injection, a loop can have multiple prompt entries at
|
|
669
|
+
// plurnk://prompt/<loop_id>/<N>; all of them get foisted, leaving
|
|
670
|
+
// only previous loops' prompts (still addressable for HIDE).
|
|
671
|
+
const foistedPrefix = `prompt/${currentLoopId}/`;
|
|
672
|
+
// Backward compat: legacy single-slot path. Tests / older runs may
|
|
673
|
+
// still have a `prompt/<loop_id>` entry (no trailing /N); foist it too.
|
|
674
|
+
const foistedExact = `prompt/${currentLoopId}`;
|
|
530
675
|
const entries = new Map();
|
|
531
676
|
for (const row of rows) {
|
|
677
|
+
if (row.scheme === "plurnk"
|
|
678
|
+
&& (row.pathname === foistedExact || row.pathname.startsWith(foistedPrefix)))
|
|
679
|
+
continue;
|
|
532
680
|
let entry = entries.get(row.entry_id);
|
|
533
681
|
if (entry === undefined) {
|
|
534
682
|
const tagRows = await tagsStmt.all({ entry_id: row.entry_id });
|
|
683
|
+
// defaultChannel pulled from the row's scheme manifest. The
|
|
684
|
+
// wire renderer uses it to omit `#channel` on fences whose
|
|
685
|
+
// channel is the scheme's default — that absence is the
|
|
686
|
+
// addressing of the default channel.
|
|
687
|
+
const handler = this.#schemes.get(row.scheme ?? "");
|
|
688
|
+
const manifest = handler?.constructor?.manifest;
|
|
535
689
|
entry = {
|
|
536
690
|
id: row.entry_id,
|
|
537
691
|
version: row.version,
|
|
@@ -545,6 +699,7 @@ export default class Engine {
|
|
|
545
699
|
pathname: row.pathname,
|
|
546
700
|
params: row.params === null ? null : JSON.parse(row.params),
|
|
547
701
|
channels: {},
|
|
702
|
+
defaultChannel: manifest?.defaultChannel ?? "",
|
|
548
703
|
attributes: JSON.parse(row.attributes),
|
|
549
704
|
tags: tagRows.map((r) => r.tag),
|
|
550
705
|
};
|
|
@@ -565,15 +720,20 @@ export default class Engine {
|
|
|
565
720
|
return [...entries.values()];
|
|
566
721
|
}
|
|
567
722
|
async dispatch(context) {
|
|
568
|
-
const { statement, sessionId, runId, loopId, turnId,
|
|
723
|
+
const { statement, sessionId, runId, loopId, turnId, sequence, origin, onDispatch } = context;
|
|
569
724
|
const schemeCtx = {
|
|
570
725
|
db: this.#db,
|
|
571
726
|
sessionId, runId, loopId, turnId,
|
|
572
727
|
writer: origin,
|
|
573
|
-
signal:
|
|
728
|
+
signal: this.#loopAborts.get(loopId)?.signal,
|
|
729
|
+
streamEventNotify: this.#streamEventNotify,
|
|
730
|
+
wakeRunNotify: this.#wakeRunNotify,
|
|
731
|
+
mimetypes: this.#mimetypes,
|
|
574
732
|
};
|
|
575
733
|
let result;
|
|
576
|
-
|
|
734
|
+
let denial = this.#checkWritable(statement, origin);
|
|
735
|
+
if (denial === null)
|
|
736
|
+
denial = await this.#checkFlagsGate(statement, loopId);
|
|
577
737
|
if (denial !== null) {
|
|
578
738
|
result = denial;
|
|
579
739
|
}
|
|
@@ -584,7 +744,7 @@ export default class Engine {
|
|
|
584
744
|
// skips it. Logging failures (#writeLog throws) are NOT caught —
|
|
585
745
|
// those are system failures.
|
|
586
746
|
try {
|
|
587
|
-
if (statement.op === "SEND" && statement.
|
|
747
|
+
if (statement.op === "SEND" && statement.target === null) {
|
|
588
748
|
result = await this.#handleSendBroadcast(statement, loopId);
|
|
589
749
|
}
|
|
590
750
|
else if (statement.op === "COPY") {
|
|
@@ -593,8 +753,15 @@ export default class Engine {
|
|
|
593
753
|
else if (statement.op === "MOVE") {
|
|
594
754
|
result = await this.#handleMove(statement, schemeCtx);
|
|
595
755
|
}
|
|
756
|
+
else if (statement.op === "EXEC") {
|
|
757
|
+
// EXEC's target slot is `cwd`, not a scheme address.
|
|
758
|
+
// Per plurnk.md the op routes unconditionally to the
|
|
759
|
+
// exec scheme; the scheme handler reads runtime
|
|
760
|
+
// (signal), cwd (target), and command (body).
|
|
761
|
+
result = await this.#run("exec", statement, schemeCtx);
|
|
762
|
+
}
|
|
596
763
|
else {
|
|
597
|
-
result = await this.#run(this.#schemeNameOf(statement.
|
|
764
|
+
result = await this.#run(this.#schemeNameOf(statement.target), statement, schemeCtx);
|
|
598
765
|
}
|
|
599
766
|
}
|
|
600
767
|
catch (err) {
|
|
@@ -604,7 +771,7 @@ export default class Engine {
|
|
|
604
771
|
};
|
|
605
772
|
}
|
|
606
773
|
}
|
|
607
|
-
const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId,
|
|
774
|
+
const logEntryId = await this.#writeLog({ statement, result, runId, loopId, turnId, sequence, origin });
|
|
608
775
|
onDispatch?.(logEntryId);
|
|
609
776
|
// Proposal lifecycle (task #42, AGENTS.md §Phase E). When a scheme
|
|
610
777
|
// returns status 202, the entry is written as state='proposed';
|
|
@@ -623,7 +790,7 @@ export default class Engine {
|
|
|
623
790
|
// Notify external listeners (Daemon broadcasts loop/proposal;
|
|
624
791
|
// YOLO listener auto-resolves) BEFORE awaiting — they may
|
|
625
792
|
// resolve synchronously inside their handlers.
|
|
626
|
-
const target = this.#extractTarget(statement.
|
|
793
|
+
const target = this.#extractTarget(statement.target);
|
|
627
794
|
const flags = await this.#loadLoopFlags(loopId);
|
|
628
795
|
const event = {
|
|
629
796
|
logEntryId, sessionId, runId, loopId, turnId,
|
|
@@ -645,26 +812,40 @@ export default class Engine {
|
|
|
645
812
|
// 4xx/5xx or throws, the resolution is downgraded to a reject
|
|
646
813
|
// with the failure outcome — engine treats it like a client
|
|
647
814
|
// rejection.
|
|
648
|
-
const effective = await this.#runApplyResolution(statement, result, resolution);
|
|
815
|
+
const effective = await this.#runApplyResolution(statement, result, resolution, { sessionId, runId, loopId, turnId });
|
|
649
816
|
const post = await this.#applyResolution(logEntryId, effective);
|
|
650
817
|
return post;
|
|
651
818
|
}
|
|
652
819
|
return result;
|
|
653
820
|
}
|
|
654
|
-
async #runApplyResolution(statement, originalResult, resolution) {
|
|
821
|
+
async #runApplyResolution(statement, originalResult, resolution, ids) {
|
|
822
|
+
const { sessionId, runId, loopId, turnId } = ids;
|
|
655
823
|
if (resolution.decision !== "accept")
|
|
656
824
|
return resolution;
|
|
657
|
-
|
|
825
|
+
// EXEC routes to the exec scheme regardless of target (cwd, not
|
|
826
|
+
// a scheme address). All other ops resolve their handler from
|
|
827
|
+
// statement.target's scheme.
|
|
828
|
+
const schemeName = statement.op === "EXEC" ? "exec" : this.#schemeNameOf(statement.target);
|
|
658
829
|
if (schemeName === null)
|
|
659
830
|
return resolution;
|
|
660
831
|
const handler = this.#schemes.get(schemeName);
|
|
661
832
|
if (handler === undefined || typeof handler.applyResolution !== "function")
|
|
662
833
|
return resolution;
|
|
663
834
|
try {
|
|
835
|
+
// Build a ctx for the scheme's applyResolution. The proposal
|
|
836
|
+
// was raised inside a specific (session, run, loop, turn);
|
|
837
|
+
// the scheme uses ctx to write the entry that makes the
|
|
838
|
+
// operation's artifact visible in the next packet's index.
|
|
839
|
+
const applyCtx = {
|
|
840
|
+
db: this.#db, sessionId, runId, loopId, turnId,
|
|
841
|
+
writer: "model", signal: this.#loopAborts.get(loopId)?.signal,
|
|
842
|
+
streamEventNotify: this.#streamEventNotify,
|
|
843
|
+
wakeRunNotify: this.#wakeRunNotify,
|
|
844
|
+
};
|
|
664
845
|
const applyResult = await handler.applyResolution({
|
|
665
846
|
attrs: (originalResult.attrs ?? {}),
|
|
666
847
|
body: resolution.body,
|
|
667
|
-
});
|
|
848
|
+
}, applyCtx);
|
|
668
849
|
if (applyResult.status >= 400) {
|
|
669
850
|
return {
|
|
670
851
|
decision: "reject",
|
|
@@ -672,6 +853,14 @@ export default class Engine {
|
|
|
672
853
|
body: applyResult.body,
|
|
673
854
|
};
|
|
674
855
|
}
|
|
856
|
+
// Propagate applyResolution.outcome onto the accepted resolution
|
|
857
|
+
// so the log entry's outcome column reflects operational metadata
|
|
858
|
+
// (e.g. exec's "exit_N"). Without this, only failures get an
|
|
859
|
+
// outcome on the durable record, and "ran cleanly but with a
|
|
860
|
+
// notable detail" has nowhere to land.
|
|
861
|
+
if (applyResult.outcome !== undefined && resolution.outcome === undefined) {
|
|
862
|
+
return { ...resolution, outcome: applyResult.outcome };
|
|
863
|
+
}
|
|
675
864
|
return resolution;
|
|
676
865
|
}
|
|
677
866
|
catch (err) {
|
|
@@ -702,6 +891,52 @@ export default class Engine {
|
|
|
702
891
|
pendingProposalIds() {
|
|
703
892
|
return [...this.#pendingProposals.keys()];
|
|
704
893
|
}
|
|
894
|
+
// Used by wake-on-completion (daemon side): "is there any loop in this
|
|
895
|
+
// run still accepting turns?" If yes, skip the wake — the active loop
|
|
896
|
+
// will pick up the channel transition at its next turn boundary. If no,
|
|
897
|
+
// the daemon opens a fresh loop with the wake prompt.
|
|
898
|
+
async hasActiveLoopForRun(runId) {
|
|
899
|
+
const row = await this.#db.engine_count_active_loops_for_run.get({ run_id: runId });
|
|
900
|
+
return (row?.n ?? 0) > 0;
|
|
901
|
+
}
|
|
902
|
+
// Inject a prompt into the run's currently-executing loop. Writes a
|
|
903
|
+
// plurnk://prompt/<loop_id>/<next-turn> entry whose body becomes
|
|
904
|
+
// packet.user.prompt at the next turn boundary. Last-wins: if two
|
|
905
|
+
// injects target the same next-turn slot, the second overwrites the
|
|
906
|
+
// first.
|
|
907
|
+
//
|
|
908
|
+
// Returns null when no loop in the run is currently active (status=102).
|
|
909
|
+
// The daemon-side inject path then enqueues a fresh loop with this
|
|
910
|
+
// prompt; engine doesn't open loops itself.
|
|
911
|
+
//
|
|
912
|
+
// Rummy parallel: AgentLoop.inject(). The "active drain → write
|
|
913
|
+
// prompt entry, return immediately" branch.
|
|
914
|
+
async inject(runId, prompt) {
|
|
915
|
+
const loopRow = await this.#db.drain_current_loop_for_run.get({ run_id: runId });
|
|
916
|
+
if (loopRow === undefined)
|
|
917
|
+
return null;
|
|
918
|
+
const loopId = loopRow.id;
|
|
919
|
+
const turnRow = await this.#db.drain_next_turn_seq_for_loop.get({ loop_id: loopId });
|
|
920
|
+
const turnSeq = turnRow?.next ?? 1;
|
|
921
|
+
const sessionRow = await this.#db.drain_get_run_session.get({ run_id: runId });
|
|
922
|
+
if (sessionRow === undefined)
|
|
923
|
+
throw new Error(`Engine.inject: run ${runId} not found`);
|
|
924
|
+
const pathname = `prompt/${loopId}/${turnSeq}`;
|
|
925
|
+
const ctx = {
|
|
926
|
+
db: this.#db, sessionId: sessionRow.session_id, runId, loopId,
|
|
927
|
+
turnId: 0, // no turn open at inject time; entries don't pin turnId
|
|
928
|
+
writer: "system",
|
|
929
|
+
signal: this.#loopAborts.get(loopId)?.signal,
|
|
930
|
+
streamEventNotify: this.#streamEventNotify,
|
|
931
|
+
wakeRunNotify: this.#wakeRunNotify,
|
|
932
|
+
};
|
|
933
|
+
const entry = {
|
|
934
|
+
channels: { body: { content: prompt, mimetype: "text/markdown" } },
|
|
935
|
+
tags: [],
|
|
936
|
+
};
|
|
937
|
+
await writeEntry(pathname, entry, ctx, "plurnk");
|
|
938
|
+
return { loopId, turnSeq };
|
|
939
|
+
}
|
|
705
940
|
// Subscribe to proposal-pending events. Daemon registers a listener
|
|
706
941
|
// that broadcasts the loop/proposal WS notification; YOLO listener
|
|
707
942
|
// (Phase E.3) registers one that auto-resolves. Listeners fire BEFORE
|
|
@@ -760,7 +995,13 @@ export default class Engine {
|
|
|
760
995
|
: decision === "reject" ? "rejected"
|
|
761
996
|
: "loop_aborted";
|
|
762
997
|
const outcome = resolution.outcome ?? defaultOutcome;
|
|
763
|
-
|
|
998
|
+
// rx is the model-facing operation result. ONLY status — outcome
|
|
999
|
+
// is operational (security/admin) and stays on the log_entries
|
|
1000
|
+
// column for forensics; body was an input echo with no value to
|
|
1001
|
+
// the model; target/path lives in log_entries metadata and
|
|
1002
|
+
// surfaces via the log section's `target` field uniformly.
|
|
1003
|
+
// Per AGENTS.md "Operational hygiene on what the model sees."
|
|
1004
|
+
const rx = JSON.stringify({ status });
|
|
764
1005
|
await this.#db.engine_resolve_log_entry.run({
|
|
765
1006
|
id: logEntryId, state, outcome, status_rx: status, rx,
|
|
766
1007
|
});
|
|
@@ -776,15 +1017,20 @@ export default class Engine {
|
|
|
776
1017
|
#checkWritable(statement, origin) {
|
|
777
1018
|
if (!MUTATING_OPS.has(statement.op))
|
|
778
1019
|
return null;
|
|
779
|
-
if (statement.op === "SEND" && statement.
|
|
1020
|
+
if (statement.op === "SEND" && statement.target === null)
|
|
780
1021
|
return null;
|
|
1022
|
+
// EXEC's target slot is `cwd`, not a scheme address. The op's
|
|
1023
|
+
// authority always belongs to the exec scheme regardless of cwd.
|
|
1024
|
+
if (statement.op === "EXEC") {
|
|
1025
|
+
return this.#denyIfDisallowed("exec", origin);
|
|
1026
|
+
}
|
|
781
1027
|
if (statement.op === "COPY" || statement.op === "MOVE") {
|
|
782
1028
|
const dstScheme = this.#schemeNameOf(statement.body);
|
|
783
1029
|
const dstDenial = this.#denyIfDisallowed(dstScheme, origin);
|
|
784
1030
|
if (dstDenial !== null)
|
|
785
1031
|
return dstDenial;
|
|
786
1032
|
if (statement.op === "MOVE") {
|
|
787
|
-
const srcScheme = this.#schemeNameOf(statement.
|
|
1033
|
+
const srcScheme = this.#schemeNameOf(statement.target);
|
|
788
1034
|
if (srcScheme !== dstScheme) {
|
|
789
1035
|
const srcDenial = this.#denyIfDisallowed(srcScheme, origin);
|
|
790
1036
|
if (srcDenial !== null)
|
|
@@ -793,7 +1039,7 @@ export default class Engine {
|
|
|
793
1039
|
}
|
|
794
1040
|
return null;
|
|
795
1041
|
}
|
|
796
|
-
const target = this.#schemeNameOf(statement.
|
|
1042
|
+
const target = this.#schemeNameOf(statement.target);
|
|
797
1043
|
return this.#denyIfDisallowed(target, origin);
|
|
798
1044
|
}
|
|
799
1045
|
#denyIfDisallowed(schemeName, origin) {
|
|
@@ -809,10 +1055,37 @@ export default class Engine {
|
|
|
809
1055
|
return null;
|
|
810
1056
|
return { status: 403, error: `writer '${origin}' is not in writableBy for scheme '${schemeName}'` };
|
|
811
1057
|
}
|
|
1058
|
+
// Per-loop flag gating. Schemes self-declare their flag affinity in
|
|
1059
|
+
// their manifest (proposes / excludedInAsk / requiresWeb /
|
|
1060
|
+
// requiresInteraction); SchemeRegistry.resolveForLoop returns the
|
|
1061
|
+
// active set under the loop's persisted flags. Anything outside the
|
|
1062
|
+
// set returns 403 — action-entry-as-outcome carries the rejection.
|
|
1063
|
+
async #checkFlagsGate(statement, loopId) {
|
|
1064
|
+
// Broadcast SEND has no scheme to gate.
|
|
1065
|
+
if (statement.op === "SEND" && statement.target === null)
|
|
1066
|
+
return null;
|
|
1067
|
+
const flags = await this.#loadLoopFlags(loopId);
|
|
1068
|
+
// Fast path: default flags gate nothing. (yolo never gates.)
|
|
1069
|
+
if (!flags.noProposals && !flags.noWeb && !flags.noInteraction && flags.mode === "act")
|
|
1070
|
+
return null;
|
|
1071
|
+
const active = this.#schemes.resolveForLoop(flags);
|
|
1072
|
+
const check = (target) => {
|
|
1073
|
+
const scheme = this.#schemeNameOf(target);
|
|
1074
|
+
if (scheme === null)
|
|
1075
|
+
return null;
|
|
1076
|
+
if (active.has(scheme))
|
|
1077
|
+
return null;
|
|
1078
|
+
return { status: 403, error: `scheme '${scheme}' is inactive under current loop flags` };
|
|
1079
|
+
};
|
|
1080
|
+
if (statement.op === "COPY" || statement.op === "MOVE") {
|
|
1081
|
+
return check(statement.target) ?? check(statement.body);
|
|
1082
|
+
}
|
|
1083
|
+
return check(statement.target);
|
|
1084
|
+
}
|
|
812
1085
|
async #handleCopy(statement, ctx) {
|
|
813
1086
|
if (statement.op !== "COPY")
|
|
814
1087
|
throw new Error("unreachable");
|
|
815
|
-
const srcPath = statement.
|
|
1088
|
+
const srcPath = statement.target;
|
|
816
1089
|
const dstPath = statement.body;
|
|
817
1090
|
if (srcPath === null)
|
|
818
1091
|
return { status: 400, error: "COPY requires source path" };
|
|
@@ -823,7 +1096,7 @@ export default class Engine {
|
|
|
823
1096
|
async #handleMove(statement, ctx) {
|
|
824
1097
|
if (statement.op !== "MOVE")
|
|
825
1098
|
throw new Error("unreachable");
|
|
826
|
-
const srcPath = statement.
|
|
1099
|
+
const srcPath = statement.target;
|
|
827
1100
|
const dstPath = statement.body;
|
|
828
1101
|
if (srcPath === null)
|
|
829
1102
|
return { status: 400, error: "MOVE requires source path" };
|
|
@@ -881,11 +1154,30 @@ export default class Engine {
|
|
|
881
1154
|
return { status: 415, error: `mimetype mismatch on channel '${channelName}': ${channelData.mimetype} vs ${expectedMimetype}` };
|
|
882
1155
|
}
|
|
883
1156
|
}
|
|
1157
|
+
// `<L>` source range slicing per AGENTS.md "Resolved ambiguities" §4
|
|
1158
|
+
// (symmetric with READ `<L>` — source range, no line-number prefix).
|
|
1159
|
+
// Applied to every channel of the source entry. Binary channels return
|
|
1160
|
+
// 415 since line semantics don't apply.
|
|
1161
|
+
const lineMarker = statement.lineMarker ?? null;
|
|
1162
|
+
let channels = entry.channels;
|
|
1163
|
+
if (lineMarker !== null) {
|
|
1164
|
+
const sliced = {};
|
|
1165
|
+
for (const [channelName, channelData] of Object.entries(entry.channels)) {
|
|
1166
|
+
if (isBinaryMimetype(channelData.mimetype)) {
|
|
1167
|
+
return { status: 415, error: `cannot slice <L> on binary channel '${channelName}' (${channelData.mimetype})` };
|
|
1168
|
+
}
|
|
1169
|
+
const r = sliceLinesRaw(channelData.content ?? "", lineMarker);
|
|
1170
|
+
if (r.status !== 200)
|
|
1171
|
+
return { status: r.status, error: r.error };
|
|
1172
|
+
sliced[channelName] = { ...channelData, content: r.text ?? "" };
|
|
1173
|
+
}
|
|
1174
|
+
channels = sliced;
|
|
1175
|
+
}
|
|
884
1176
|
// Tag resolution: signal = replace; absent/empty = carry from source
|
|
885
1177
|
const tags = (Array.isArray(statement.signal) && statement.signal.length > 0)
|
|
886
1178
|
? statement.signal
|
|
887
1179
|
: entry.tags;
|
|
888
|
-
const writeResult = await dstHandler.writeEntry(dstPathname, { channels
|
|
1180
|
+
const writeResult = await dstHandler.writeEntry(dstPathname, { channels, tags }, ctx);
|
|
889
1181
|
return { status: writeResult.status, entryId: writeResult.entryId, created: writeResult.created };
|
|
890
1182
|
}
|
|
891
1183
|
async #handleSendBroadcast(statement, loopId) {
|
|
@@ -921,8 +1213,8 @@ export default class Engine {
|
|
|
921
1213
|
return path.scheme;
|
|
922
1214
|
return "file"; // local (bare) → file
|
|
923
1215
|
}
|
|
924
|
-
async #writeLog({ statement, result, runId, loopId, turnId,
|
|
925
|
-
const target = this.#extractTarget(statement.
|
|
1216
|
+
async #writeLog({ statement, result, runId, loopId, turnId, sequence, origin, }) {
|
|
1217
|
+
const target = this.#extractTarget(statement.target);
|
|
926
1218
|
const lineMarkerJson = "lineMarker" in statement && statement.lineMarker !== null
|
|
927
1219
|
? JSON.stringify(statement.lineMarker)
|
|
928
1220
|
: null;
|
|
@@ -940,19 +1232,19 @@ export default class Engine {
|
|
|
940
1232
|
run_id: runId,
|
|
941
1233
|
loop_id: loopId,
|
|
942
1234
|
turn_id: turnId,
|
|
943
|
-
|
|
1235
|
+
sequence: sequence,
|
|
944
1236
|
origin,
|
|
945
1237
|
op: statement.op,
|
|
946
1238
|
suffix: statement.suffix,
|
|
947
1239
|
signal: this.#signalToJson(statement.signal),
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1240
|
+
scheme: target.scheme,
|
|
1241
|
+
username: target.username,
|
|
1242
|
+
password: target.password,
|
|
1243
|
+
hostname: target.hostname,
|
|
1244
|
+
port: target.port,
|
|
1245
|
+
pathname: target.pathname,
|
|
1246
|
+
params: target.params,
|
|
1247
|
+
fragment: target.fragment,
|
|
956
1248
|
lineMarker: lineMarkerJson,
|
|
957
1249
|
tx: JSON.stringify(statement),
|
|
958
1250
|
mimetype_tx: "application/json",
|
|
@@ -967,13 +1259,18 @@ export default class Engine {
|
|
|
967
1259
|
throw new Error("Engine.#writeLog: INSERT ... RETURNING produced no row");
|
|
968
1260
|
return row.id;
|
|
969
1261
|
}
|
|
1262
|
+
// Normalize a parsed path for storage. The `file` scheme is a routing
|
|
1263
|
+
// internal — never stored, never rendered to the model. Both bare paths
|
|
1264
|
+
// and `file://...` inputs collapse to scheme=null at this boundary, so
|
|
1265
|
+
// entries.scheme / log_entries.scheme never carry the string "file".
|
|
970
1266
|
#extractTarget(path) {
|
|
971
1267
|
if (path === null)
|
|
972
1268
|
return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: null, params: null, fragment: null };
|
|
973
1269
|
if (path.kind === "local")
|
|
974
1270
|
return { scheme: null, username: null, password: null, hostname: null, port: null, pathname: path.raw, params: null, fragment: null };
|
|
1271
|
+
const scheme = path.scheme === "file" ? null : path.scheme;
|
|
975
1272
|
return {
|
|
976
|
-
scheme
|
|
1273
|
+
scheme, username: path.username, password: path.password,
|
|
977
1274
|
hostname: path.hostname, port: path.port, pathname: path.pathname,
|
|
978
1275
|
params: JSON.stringify(path.params), fragment: path.fragment,
|
|
979
1276
|
};
|