pi-onlyne 1.1.2 → 1.2.1
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 +377 -43
- package/README.zh.md +73 -33
- package/package.json +1 -1
- package/src/agent.live.test.mjs +1 -0
- package/src/agent.mjs +233 -97
- package/src/agent.test.mjs +360 -86
- package/src/background-work.mjs +176 -0
- package/src/background-work.test.mjs +128 -0
- package/src/config.mjs +27 -5
- package/src/index.ts +42 -3
- package/src/pi-surface.mjs +40 -0
- package/src/protocol.mjs +41 -49
- package/src/protocol.test.mjs +58 -8
package/src/agent.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// The agent side of the onlyne adapter protocol: one connection to
|
|
2
2
|
// `<role workspace>/.onlyne/run/s`, the hello/welcome handshake, assign
|
|
3
|
-
// delivery, turn-state reports, the completion exit
|
|
4
|
-
// detach — plus reconnect when the
|
|
3
|
+
// delivery, turn-state reports, the completion exit and the idle reminder
|
|
4
|
+
// ladder that leads to it, probe, recycle and detach — plus reconnect when the
|
|
5
|
+
// client restarts under it.
|
|
5
6
|
//
|
|
6
7
|
// Everything pi-specific lives behind `surface` (see pi-surface.mjs): this
|
|
7
8
|
// module decides *what* the protocol says and hands the *effects* to the
|
|
@@ -19,6 +20,7 @@ import { createConnection } from "node:net";
|
|
|
19
20
|
import { basename, join } from "node:path";
|
|
20
21
|
import { createFrameDecoder, encodeFrame } from "./frame.mjs";
|
|
21
22
|
import { createActivity } from "./activity.mjs";
|
|
23
|
+
import { DEFAULT_IDLE_REMINDERS } from "./config.mjs";
|
|
22
24
|
import {
|
|
23
25
|
DEFAULT_HEARTBEAT_MS,
|
|
24
26
|
assignAckArgs,
|
|
@@ -36,7 +38,6 @@ import {
|
|
|
36
38
|
sendEnvelope,
|
|
37
39
|
sessionRegisterArgs,
|
|
38
40
|
SEQ_BASE,
|
|
39
|
-
settledReport,
|
|
40
41
|
stdinTaskText,
|
|
41
42
|
welcomeFrom,
|
|
42
43
|
} from "./protocol.mjs";
|
|
@@ -93,6 +94,7 @@ export class OnlyneAgent {
|
|
|
93
94
|
* requestTimeoutMs?: number,
|
|
94
95
|
* helloTimeoutMs?: number,
|
|
95
96
|
* settleFallbackMs?: number,
|
|
97
|
+
* idleReminders?: number,
|
|
96
98
|
* createConnection?: (path: string) => any,
|
|
97
99
|
* timer?: { set: (fn: () => void, ms: number) => any, clear: (handle: any) => void },
|
|
98
100
|
* }} options
|
|
@@ -116,6 +118,12 @@ export class OnlyneAgent {
|
|
|
116
118
|
this.requestTimeoutMs = options.requestTimeoutMs ?? REQUEST_TIMEOUT_MS;
|
|
117
119
|
this.helloTimeoutMs = options.helloTimeoutMs ?? HELLO_TIMEOUT_MS;
|
|
118
120
|
this.settleFallbackMs = options.settleFallbackMs ?? SETTLE_FALLBACK_MS;
|
|
121
|
+
/**
|
|
122
|
+
* How many idle reminders one task may collect: the bound that turns the
|
|
123
|
+
* third idle without a completion into a failure (`settleNow`). It comes
|
|
124
|
+
* from the workspace's `.pi/onlyne.json` (`config.mjs`).
|
|
125
|
+
*/
|
|
126
|
+
this.idleReminders = options.idleReminders ?? DEFAULT_IDLE_REMINDERS;
|
|
119
127
|
this.createConnection = options.createConnection ?? ((path) => createConnection(path));
|
|
120
128
|
// The pane this process was spawned in, reported on every heartbeat so the
|
|
121
129
|
// supervisor board can attribute the tab (protocol.mjs `hostBinding`). Read
|
|
@@ -167,8 +175,6 @@ export class OnlyneAgent {
|
|
|
167
175
|
this.lastError = null;
|
|
168
176
|
/** Whether pi has already been asked to end this process (`exitSession`). */
|
|
169
177
|
this.exitRequested = false;
|
|
170
|
-
/** Agent phase of the last heartbeat this connection sent, if any. */
|
|
171
|
-
this.lastPhase = null;
|
|
172
178
|
this.stats = { assigns: 0, duplicates: 0, injections: 0, completions: 0, reports: 0, reconnects: 0, recycles: 0 };
|
|
173
179
|
}
|
|
174
180
|
|
|
@@ -317,9 +323,10 @@ export class OnlyneAgent {
|
|
|
317
323
|
}
|
|
318
324
|
this.welcome = welcome;
|
|
319
325
|
this.connected = true;
|
|
320
|
-
// A fresh connection has reported nothing
|
|
321
|
-
|
|
322
|
-
|
|
326
|
+
// A fresh connection has reported nothing, so the phase is a question, not
|
|
327
|
+
// an answer: the first beat below re-derives it from pi. A taskless session
|
|
328
|
+
// is the ready pool, and ready is its own state.
|
|
329
|
+
this.agentState = this.tasks.size > 0 ? await this.derivedPhase() : "ready";
|
|
323
330
|
this.activity.set({
|
|
324
331
|
role: welcome.role,
|
|
325
332
|
connection: "connected",
|
|
@@ -492,33 +499,55 @@ export class OnlyneAgent {
|
|
|
492
499
|
}
|
|
493
500
|
|
|
494
501
|
/**
|
|
495
|
-
* One heartbeat for the task this connection serves.
|
|
502
|
+
* One heartbeat for the task this connection serves. The phase is the one the
|
|
503
|
+
* rule names: `idle` only while the session waits for user input, `running`
|
|
504
|
+
* for everything else, re-derived from pi on every beat so a phase that went
|
|
505
|
+
* stale when a run started again cannot survive a tick.
|
|
496
506
|
*
|
|
497
|
-
* A task this plugin already completed gets none
|
|
498
|
-
* snapshot
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
507
|
+
* A task this plugin already completed gets none: the beat is a full
|
|
508
|
+
* snapshot of what the plugin can see, and after the completion the agent's
|
|
509
|
+
* remaining turns are not this session's business — the row belongs to the
|
|
510
|
+
* settlement the completion earned. The client rewrites the tuple's `delivery`
|
|
511
|
+
* and `recovery` from its own records, so a late beat could no longer undo
|
|
512
|
+
* the drain even if it sent one. The e2e case
|
|
513
|
+
* `crates/onlyne-testkit/e2e/pi-live.sh` caught this race, where a turn-end
|
|
514
|
+
* heartbeat left over from the finishing turn landed three milliseconds
|
|
515
|
+
* behind the completion.
|
|
505
516
|
*/
|
|
506
|
-
async heartbeat(agent =
|
|
517
|
+
async heartbeat(agent = null) {
|
|
507
518
|
if (!this.connected) return;
|
|
508
519
|
const taskId = this.activeTaskId();
|
|
509
520
|
if (!taskId) return;
|
|
510
521
|
if (this.tasks.get(taskId)?.completed) return;
|
|
511
|
-
|
|
522
|
+
const phase = agent ?? (await this.derivedPhase());
|
|
523
|
+
this.agentState = phase;
|
|
512
524
|
this.seq += 1;
|
|
513
525
|
await this.request("report", heartbeatReport({
|
|
514
526
|
taskId,
|
|
515
527
|
generation: this.generation,
|
|
516
528
|
seq: this.seq,
|
|
517
|
-
agent,
|
|
529
|
+
agent: phase,
|
|
518
530
|
host: this.host,
|
|
519
531
|
}));
|
|
520
532
|
this.stats.reports += 1;
|
|
521
|
-
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Where the session actually is, in the only two words the wire has for it.
|
|
537
|
+
* The surface asks pi, and adds the one case pi cannot see: work a
|
|
538
|
+
* background-task extension took off the agent loop.
|
|
539
|
+
* @returns {Promise<"idle"|"running">}
|
|
540
|
+
*/
|
|
541
|
+
async derivedPhase() {
|
|
542
|
+
try {
|
|
543
|
+
const waiting = await this.surface.waitingForInput?.();
|
|
544
|
+
return waiting === true ? "idle" : "running";
|
|
545
|
+
} catch (error) {
|
|
546
|
+
// A surface that cannot answer has not witnessed a session waiting for
|
|
547
|
+
// input, and the rule reads that as running.
|
|
548
|
+
this.log(`input-waiting probe failed: ${error.message}`);
|
|
549
|
+
return "running";
|
|
550
|
+
}
|
|
522
551
|
}
|
|
523
552
|
|
|
524
553
|
/** Tasks still owing a completion; a finished task keeps its record. */
|
|
@@ -584,10 +613,16 @@ export class OnlyneAgent {
|
|
|
584
613
|
if (typeof args.generation === "number") this.generation = args.generation;
|
|
585
614
|
|
|
586
615
|
const attachments = this.writeAttachments(taskId, envelope);
|
|
616
|
+
const attachmentPaths = attachments.map((item) => item.path);
|
|
587
617
|
const prose = typeof args.prose === "string" ? args.prose.trim() : "";
|
|
588
618
|
const proseIsNew = prose.length > 0 && !this.deliveredProse.has(prose);
|
|
589
619
|
if (proseIsNew) this.deliveredProse.add(prose);
|
|
590
|
-
|
|
620
|
+
// The assignment as the injection saw it, retained so the idle ladder can
|
|
621
|
+
// re-send it (`remind`): the task identity, its source, its kind, the task
|
|
622
|
+
// text with whatever handoff lines a relay put inside it, and the paths of
|
|
623
|
+
// the attachments this call already wrote.
|
|
624
|
+
const assignment = { ...args, task_id: taskId };
|
|
625
|
+
const text = injectionText({ assign: assignment, proseIsNew, attachmentPaths });
|
|
591
626
|
|
|
592
627
|
const held = this.tasks.get(taskId);
|
|
593
628
|
// A completed record is not a live one: a new envelope for a task that
|
|
@@ -598,9 +633,17 @@ export class OnlyneAgent {
|
|
|
598
633
|
// is: its delivered set keeps the relay guard's count, and its completion
|
|
599
634
|
// state still settles the task. Only the "since this instruction" counter
|
|
600
635
|
// moves, so the settled-without-completing watchdog measures the newest one.
|
|
636
|
+
// The ladder restarts with it: the reminder count, the idle episode it was
|
|
637
|
+
// charged to (`remindedAt`), and the failure an errored turn proved all
|
|
638
|
+
// belong to the instruction being replaced.
|
|
601
639
|
held.turnsSinceAssign = 0;
|
|
640
|
+
held.reminders = 0;
|
|
641
|
+
held.remindedAt = 0;
|
|
642
|
+
held.reminderWokeTurn = false;
|
|
643
|
+
held.errored = false;
|
|
602
644
|
held.envelopeId = envelope.id ?? held.envelopeId;
|
|
603
|
-
|
|
645
|
+
held.assignment = assignment;
|
|
646
|
+
held.attachmentPaths = attachmentPaths;
|
|
604
647
|
} else {
|
|
605
648
|
this.tasks.set(taskId, {
|
|
606
649
|
taskId,
|
|
@@ -612,7 +655,14 @@ export class OnlyneAgent {
|
|
|
612
655
|
turns: 0,
|
|
613
656
|
errored: false,
|
|
614
657
|
head: "",
|
|
615
|
-
|
|
658
|
+
assignment,
|
|
659
|
+
attachmentPaths,
|
|
660
|
+
/** Idle reminders sent for this task; the bound is `idleReminders`. */
|
|
661
|
+
reminders: 0,
|
|
662
|
+
/** The `turnsSinceAssign` the last reminder was charged to. */
|
|
663
|
+
remindedAt: 0,
|
|
664
|
+
/** Whether the next turn is the one this plugin's own reminder woke. */
|
|
665
|
+
reminderWokeTurn: false,
|
|
616
666
|
});
|
|
617
667
|
}
|
|
618
668
|
this.agentState = "running";
|
|
@@ -675,62 +725,87 @@ export class OnlyneAgent {
|
|
|
675
725
|
|
|
676
726
|
// ------------------------------------------------------------ pi → plugin
|
|
677
727
|
|
|
678
|
-
/**
|
|
728
|
+
/**
|
|
729
|
+
* A turn started: the plugin's own agent fact is `running`, and the idle
|
|
730
|
+
* ladder starts over. The count belongs to one idle episode, so a session
|
|
731
|
+
* that ran again owns a fresh bound; the one turn this plugin's own reminder
|
|
732
|
+
* woke belongs to the episode that reminder belongs to, and keeps it.
|
|
733
|
+
*/
|
|
679
734
|
onTurnStart() {
|
|
680
|
-
const task
|
|
681
|
-
|
|
735
|
+
for (const task of this.tasks.values()) {
|
|
736
|
+
if (task.completed) continue;
|
|
737
|
+
task.turns += 1;
|
|
738
|
+
if (task.reminderWokeTurn) {
|
|
739
|
+
task.reminderWokeTurn = false;
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
task.turnsSinceAssign = 0;
|
|
743
|
+
task.reminders = 0;
|
|
744
|
+
task.remindedAt = 0;
|
|
745
|
+
}
|
|
682
746
|
void this.heartbeat("running").catch((error) => this.log(`heartbeat refused: ${error.message}`));
|
|
683
747
|
}
|
|
684
748
|
|
|
685
|
-
/**
|
|
749
|
+
/**
|
|
750
|
+
* A turn ended: one turn of a run that may still have more, and the settle
|
|
751
|
+
* window starts. The phase is re-derived, so the beat says `running` while pi
|
|
752
|
+
* keeps working and says `idle` only once the session waits for input.
|
|
753
|
+
*/
|
|
686
754
|
onTurnEnd() {
|
|
687
755
|
for (const task of this.tasks.values()) {
|
|
688
756
|
if (!task.completed) task.turnsSinceAssign += 1;
|
|
689
757
|
}
|
|
690
|
-
void this.heartbeat(
|
|
758
|
+
void this.heartbeat().catch((error) => this.log(`heartbeat refused: ${error.message}`));
|
|
691
759
|
this.armSettleFallback();
|
|
692
760
|
}
|
|
693
761
|
|
|
694
|
-
/**
|
|
762
|
+
/**
|
|
763
|
+
* pi has settled: this is the moment the rule names as waiting for input, and
|
|
764
|
+
* the settle decision belongs to it.
|
|
765
|
+
*/
|
|
695
766
|
onSettled() {
|
|
696
767
|
this.clearSettleFallback();
|
|
697
|
-
this.
|
|
768
|
+
void this.heartbeat().catch((error) => this.log(`heartbeat refused: ${error.message}`));
|
|
769
|
+
void this.trySettle().catch((error) => this.log(`settle decision failed: ${error.message}`));
|
|
698
770
|
}
|
|
699
771
|
|
|
700
772
|
/**
|
|
701
|
-
* The one
|
|
702
|
-
* retrying, compacting, or holding a queued continuation,
|
|
703
|
-
*
|
|
704
|
-
*
|
|
773
|
+
* The one settle decision. pi keeps `isIdle()` false while it is running,
|
|
774
|
+
* retrying, compacting, or holding a queued continuation, and a background task
|
|
775
|
+
* keeps work running after pi itself has settled, so a settle signal that
|
|
776
|
+
* arrives during any of those waits re-arms instead of deciding on a session
|
|
777
|
+
* that is still busy. The fallback timer and `agent_settled` both land here,
|
|
778
|
+
* and `settleNow` makes the decision idempotent for one idle episode.
|
|
705
779
|
*/
|
|
706
|
-
trySettle() {
|
|
780
|
+
async trySettle() {
|
|
707
781
|
if (this.closed || this.activeTasks().length === 0) return;
|
|
708
|
-
if (this.
|
|
782
|
+
if (await this.derivedPhase() !== "idle") {
|
|
709
783
|
this.armSettleFallback();
|
|
710
784
|
return;
|
|
711
785
|
}
|
|
712
|
-
|
|
786
|
+
await this.settleNow().catch((error) => this.log(`settle decision failed: ${error.message}`));
|
|
713
787
|
}
|
|
714
788
|
|
|
715
|
-
/** A failed turn: the task's outcome is `failed
|
|
789
|
+
/** A failed turn: the task's outcome is `failed`, with the error as its head. */
|
|
716
790
|
onTurnError(text) {
|
|
717
791
|
for (const task of this.tasks.values()) {
|
|
718
792
|
if (task.completed) continue;
|
|
719
|
-
task.failed = true;
|
|
720
793
|
task.errored = true;
|
|
721
794
|
if (text) task.head = headOf(text);
|
|
722
795
|
}
|
|
723
|
-
this.trySettle();
|
|
796
|
+
void this.trySettle().catch((error) => this.log(`settle decision failed: ${error.message}`));
|
|
724
797
|
}
|
|
725
798
|
|
|
726
799
|
/**
|
|
727
|
-
* The last assistant text seen, kept as the completion summary for a task
|
|
728
|
-
*
|
|
800
|
+
* The last assistant text seen, kept as the completion summary for a task
|
|
801
|
+
* whose `onlyne_complete` call hands no argument over.
|
|
729
802
|
*
|
|
730
803
|
* This is the fallback, never the deliverable: `onlyne_complete`'s `text` is
|
|
731
804
|
* reported byte for byte by `completeFromTool` and is never written back
|
|
732
805
|
* here, so the sentence a turn happened to end on cannot stand in for a
|
|
733
|
-
* payload the tool call carried.
|
|
806
|
+
* payload the tool call carried. Nothing else reads it: an idle that runs out
|
|
807
|
+
* of reminders fails the task and says why, rather than reporting the text of
|
|
808
|
+
* a turn nobody completed.
|
|
734
809
|
*/
|
|
735
810
|
noteAssistantText(text) {
|
|
736
811
|
const flat = headOf(text);
|
|
@@ -745,7 +820,7 @@ export class OnlyneAgent {
|
|
|
745
820
|
if (this.closed || this.activeTasks().length === 0) return;
|
|
746
821
|
this.settleHandle = this.timer.set(() => {
|
|
747
822
|
this.settleHandle = null;
|
|
748
|
-
this.trySettle();
|
|
823
|
+
void this.trySettle().catch((error) => this.log(`settle decision failed: ${error.message}`));
|
|
749
824
|
}, this.settleFallbackMs);
|
|
750
825
|
}
|
|
751
826
|
|
|
@@ -756,26 +831,81 @@ export class OnlyneAgent {
|
|
|
756
831
|
}
|
|
757
832
|
|
|
758
833
|
/**
|
|
759
|
-
* The
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
834
|
+
* The idle ladder's decision, taken for every task this session still holds
|
|
835
|
+
* when a settle signal finds the agent idle.
|
|
836
|
+
*
|
|
837
|
+
* An errored turn is proof on its own — pi may skip the clean turn end — so
|
|
838
|
+
* its task is reported `failed` at once, with the error as its head. Every
|
|
839
|
+
* other open task gets one rung of the ladder: the plugin re-sends the
|
|
840
|
+
* assignment (`remind`) and counts it, and the idle that finds the bound
|
|
841
|
+
* already spent reports `failed` and leaves the session.
|
|
842
|
+
*
|
|
843
|
+
* `onlyne_complete` is the only path to `done`. A turn that ends without it
|
|
844
|
+
* leaves the task `idle_waiting` (the design's own word for it), and the
|
|
845
|
+
* answer is the reinforcing prompt the model gets instead of a completion it
|
|
846
|
+
* never claimed.
|
|
847
|
+
*
|
|
848
|
+
* A task whose injected message has not run a turn is left alone at every
|
|
849
|
+
* rung: the reminder would re-send an assignment the model may not have read
|
|
850
|
+
* yet, and failing now would claim work that never happened.
|
|
851
|
+
*
|
|
852
|
+
* A rung belongs to an idle episode, not to a settle signal: `remindedAt`
|
|
853
|
+
* records the `turnsSinceAssign` the last reminder was charged to, so the
|
|
854
|
+
* fallback timer and an `agent_settled` answering the same turn end spend one
|
|
855
|
+
* rung between them.
|
|
765
856
|
*/
|
|
766
857
|
async settleNow() {
|
|
767
858
|
for (const task of [...this.tasks.values()]) {
|
|
768
859
|
if (task.completed) continue;
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
860
|
+
if (task.errored) {
|
|
861
|
+
await this.complete(task.taskId, "failed", task.head);
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
if (task.turnsSinceAssign === 0 || task.remindedAt === task.turnsSinceAssign) continue;
|
|
865
|
+
task.remindedAt = task.turnsSinceAssign;
|
|
866
|
+
if (task.reminders < this.idleReminders) {
|
|
867
|
+
this.remind(task);
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
const rungs = `${task.reminders} idle reminder${task.reminders === 1 ? "" : "s"}`;
|
|
871
|
+
await this.complete(task.taskId, "failed", `no completion after ${rungs}`);
|
|
773
872
|
}
|
|
774
873
|
}
|
|
775
874
|
|
|
776
875
|
/**
|
|
777
|
-
*
|
|
778
|
-
*
|
|
876
|
+
* One rung of the idle ladder: hand the assignment back to the model.
|
|
877
|
+
*
|
|
878
|
+
* The reminder is the injection's own text — `injectionText` with the prose
|
|
879
|
+
* flag off, so the role prose already in the session's context is not
|
|
880
|
+
* repeated — under one line saying why it is back. The task text is where a
|
|
881
|
+
* relay carries its handoff lines, so a handoff the model still owes comes
|
|
882
|
+
* back with it.
|
|
883
|
+
*
|
|
884
|
+
* The attachments travel as the paths the injection wrote, never as parts:
|
|
885
|
+
* the images were handed to pi once, and re-attaching them would put the same
|
|
886
|
+
* bytes into the context a second time.
|
|
887
|
+
*
|
|
888
|
+
* @param {any} task a record with `assignment` and `attachmentPaths` retained
|
|
889
|
+
* from `onAssign`
|
|
890
|
+
*/
|
|
891
|
+
remind(task) {
|
|
892
|
+
task.reminders += 1;
|
|
893
|
+
// The turn this reminder is about to wake belongs to the same episode, so
|
|
894
|
+
// `onTurnStart` spends no reset on it and the bound stays reachable.
|
|
895
|
+
task.reminderWokeTurn = true;
|
|
896
|
+
const rung = `reminder ${task.reminders} of ${this.idleReminders}`;
|
|
897
|
+
const text = [
|
|
898
|
+
`[onlyne] your turn ended without a completion exit; this task is still open (${rung}). Call onlyne_complete when it is finished.`,
|
|
899
|
+
"",
|
|
900
|
+
injectionText({ assign: task.assignment, proseIsNew: false, attachmentPaths: task.attachmentPaths }),
|
|
901
|
+
].join("\n");
|
|
902
|
+
this.surface.wakeUser?.(text, []);
|
|
903
|
+
this.notice("out", `${rung} for task ${task.taskId.slice(0, 8)}`);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* `onlyne_complete`: the model's explicit outcome, and the only path to
|
|
908
|
+
* `done`.
|
|
779
909
|
*
|
|
780
910
|
* A non-empty `text` is the completion body: it is what the model handed
|
|
781
911
|
* over, reported verbatim as the ledger's one-line `head`, so the last
|
|
@@ -854,11 +984,6 @@ export class OnlyneAgent {
|
|
|
854
984
|
this.activity.set({ taskId: this.activeTaskId() ?? null, phase: normalized });
|
|
855
985
|
this.notice("out", `complete ${taskId.slice(0, 8)} ${normalized}${summary ? `: ${summary}` : ""}`);
|
|
856
986
|
if (this.activeTasks().length === 0) {
|
|
857
|
-
if (exitProcess) {
|
|
858
|
-
await this.reportSettled(taskId, normalized).catch((error) =>
|
|
859
|
-
this.log(`settled observation refused: ${error.message}`),
|
|
860
|
-
);
|
|
861
|
-
}
|
|
862
987
|
this.stopHeartbeat();
|
|
863
988
|
if (exitProcess) this.exitSession(normalized);
|
|
864
989
|
}
|
|
@@ -880,33 +1005,6 @@ export class OnlyneAgent {
|
|
|
880
1005
|
this.surface.exit?.(reason);
|
|
881
1006
|
}
|
|
882
1007
|
|
|
883
|
-
/**
|
|
884
|
-
* One last observation before the process leaves: the tuple the host settled
|
|
885
|
-
* plus `agent: idle`.
|
|
886
|
-
*
|
|
887
|
-
* The completion settles the row from the tuple the client holds, which still
|
|
888
|
-
* says `running` when the turn that finished was the last report sent, and
|
|
889
|
-
* nothing observes the process afterwards. This report is what makes an
|
|
890
|
-
* exited session read idle. It is skipped when the last beat was already
|
|
891
|
-
* idle — the settled tuple is then already right — and it is a request for
|
|
892
|
-
* the same reason the completion is: the answer is the handover, and a
|
|
893
|
-
* failure here must not stop the exit that the durable completion earned.
|
|
894
|
-
*/
|
|
895
|
-
async reportSettled(taskId, outcome) {
|
|
896
|
-
if (!this.connected || this.lastPhase === "idle") return false;
|
|
897
|
-
this.seq += 1;
|
|
898
|
-
await this.request("report", settledReport({
|
|
899
|
-
taskId,
|
|
900
|
-
outcome,
|
|
901
|
-
generation: this.generation,
|
|
902
|
-
seq: this.seq,
|
|
903
|
-
host: this.host,
|
|
904
|
-
}));
|
|
905
|
-
this.stats.reports += 1;
|
|
906
|
-
this.lastPhase = "idle";
|
|
907
|
-
return true;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
1008
|
async flushPendingCompletion() {
|
|
911
1009
|
const pending = this.pendingCompletion;
|
|
912
1010
|
if (!pending || !this.connected) return;
|
|
@@ -917,9 +1015,6 @@ export class OnlyneAgent {
|
|
|
917
1015
|
this.activity.set({ taskId: this.activeTaskId() ?? null, phase: pending.outcome });
|
|
918
1016
|
this.notice("out", `complete ${pending.taskId.slice(0, 8)} ${pending.outcome} flushed after reconnect`);
|
|
919
1017
|
if (pending.exitProcess && this.activeTasks().length === 0) {
|
|
920
|
-
await this.reportSettled(pending.taskId, pending.outcome).catch((error) =>
|
|
921
|
-
this.log(`settled observation refused: ${error.message}`),
|
|
922
|
-
);
|
|
923
1018
|
this.exitSession(pending.outcome);
|
|
924
1019
|
}
|
|
925
1020
|
} catch (error) {
|
|
@@ -928,6 +1023,21 @@ export class OnlyneAgent {
|
|
|
928
1023
|
}
|
|
929
1024
|
}
|
|
930
1025
|
|
|
1026
|
+
/**
|
|
1027
|
+
* One inline image read off the path the model named, in the wire's
|
|
1028
|
+
* `ImagePart` shape. `mimeForPath` refuses an extension outside the core's
|
|
1029
|
+
* four mime types, and `imagePart` refuses a payload above the byte ceiling.
|
|
1030
|
+
* @param {string | null | undefined} path
|
|
1031
|
+
*/
|
|
1032
|
+
imageFromPath(path) {
|
|
1033
|
+
if (!path) return null;
|
|
1034
|
+
return imagePart({
|
|
1035
|
+
data: readFileSync(path),
|
|
1036
|
+
mime: mimeForPath(path),
|
|
1037
|
+
name: path.split("/").pop() ?? null,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
|
|
931
1041
|
/**
|
|
932
1042
|
* `onlyne_send`: submit one envelope.
|
|
933
1043
|
* @param {{ to: string, text?: string, kind?: string, imagePath?: string | null }} input
|
|
@@ -935,11 +1045,7 @@ export class OnlyneAgent {
|
|
|
935
1045
|
async sendFromTool(input) {
|
|
936
1046
|
if (!this.connected) throw new Error("onlyne: client socket is not connected");
|
|
937
1047
|
const kind = input.kind === "task" ? "task" : "note";
|
|
938
|
-
|
|
939
|
-
if (input.imagePath) {
|
|
940
|
-
const bytes = readFileSync(input.imagePath);
|
|
941
|
-
image = imagePart({ data: bytes, mime, name: input.imagePath.split("/").pop() ?? null });
|
|
942
|
-
}
|
|
1048
|
+
const image = this.imageFromPath(input.imagePath);
|
|
943
1049
|
const envelope = sendEnvelope({ from: this.role, to: input.to, kind, text: input.text ?? "", image });
|
|
944
1050
|
const data = await this.request("send", envelope);
|
|
945
1051
|
// Recorded only after the client answered the `send`: a refused envelope was
|
|
@@ -949,6 +1055,36 @@ export class OnlyneAgent {
|
|
|
949
1055
|
return { queued: true, op_id: envelope.op_id ?? null, kind, to: input.to, data };
|
|
950
1056
|
}
|
|
951
1057
|
|
|
1058
|
+
/**
|
|
1059
|
+
* `onlyne_handoff`: hand this session's task on to the next hop of its family.
|
|
1060
|
+
*
|
|
1061
|
+
* The task named in the request is the session's own current one, so the child
|
|
1062
|
+
* the host mints under it is a continuation of the family this session serves:
|
|
1063
|
+
* the host reads the parent's causality, derives the child through
|
|
1064
|
+
* `Causality::child_of`, and answers the child's task id and hop. The client's
|
|
1065
|
+
* own refusal is raised out of here as it arrived.
|
|
1066
|
+
* @param {{ to: string, text?: string, imagePath?: string | null }} input
|
|
1067
|
+
*/
|
|
1068
|
+
async handoffFromTool(input) {
|
|
1069
|
+
if (!this.connected) throw new Error("onlyne: client socket is not connected");
|
|
1070
|
+
const taskId = this.activeTaskId();
|
|
1071
|
+
if (!taskId) throw new Error("onlyne: no task is assigned to this session");
|
|
1072
|
+
const data = await this.request("handoff", {
|
|
1073
|
+
task_id: taskId,
|
|
1074
|
+
to: input.to,
|
|
1075
|
+
text: input.text ?? "",
|
|
1076
|
+
image: this.imageFromPath(input.imagePath),
|
|
1077
|
+
});
|
|
1078
|
+
const answer = data ?? {};
|
|
1079
|
+
return {
|
|
1080
|
+
taskId: answer.task_id ?? null,
|
|
1081
|
+
hop: answer.hop ?? null,
|
|
1082
|
+
queued: answer.queued ?? false,
|
|
1083
|
+
opId: answer.op_id ?? null,
|
|
1084
|
+
to: input.to,
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
|
|
952
1088
|
// ------------------------------------------------------------ attachments
|
|
953
1089
|
|
|
954
1090
|
/**
|