pi-onlyne 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -34
- package/README.zh.md +47 -26
- package/package.json +1 -1
- package/src/agent.mjs +175 -49
- package/src/agent.test.mjs +245 -31
- package/src/config.mjs +27 -5
- package/src/index.ts +35 -3
- 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
|
|
@@ -494,14 +502,15 @@ export class OnlyneAgent {
|
|
|
494
502
|
/**
|
|
495
503
|
* One heartbeat for the task this connection serves.
|
|
496
504
|
*
|
|
497
|
-
* A task this plugin already completed gets none
|
|
498
|
-
* snapshot
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
*
|
|
504
|
-
*
|
|
505
|
+
* A task this plugin already completed gets none: the beat is a full
|
|
506
|
+
* snapshot of what the plugin can see, and after the completion the agent's
|
|
507
|
+
* remaining turns are not this session's business — the row belongs to the
|
|
508
|
+
* settlement the completion earned. The client rewrites the tuple's `delivery`
|
|
509
|
+
* and `recovery` from its own records, so a late beat could no longer undo
|
|
510
|
+
* the drain even if it sent one. The e2e case
|
|
511
|
+
* `crates/onlyne-testkit/e2e/pi-live.sh` caught this race, where a turn-end
|
|
512
|
+
* heartbeat left over from the finishing turn landed three milliseconds
|
|
513
|
+
* behind the completion.
|
|
505
514
|
*/
|
|
506
515
|
async heartbeat(agent = this.agentState) {
|
|
507
516
|
if (!this.connected) return;
|
|
@@ -584,10 +593,16 @@ export class OnlyneAgent {
|
|
|
584
593
|
if (typeof args.generation === "number") this.generation = args.generation;
|
|
585
594
|
|
|
586
595
|
const attachments = this.writeAttachments(taskId, envelope);
|
|
596
|
+
const attachmentPaths = attachments.map((item) => item.path);
|
|
587
597
|
const prose = typeof args.prose === "string" ? args.prose.trim() : "";
|
|
588
598
|
const proseIsNew = prose.length > 0 && !this.deliveredProse.has(prose);
|
|
589
599
|
if (proseIsNew) this.deliveredProse.add(prose);
|
|
590
|
-
|
|
600
|
+
// The assignment as the injection saw it, retained so the idle ladder can
|
|
601
|
+
// re-send it (`remind`): the task identity, its source, its kind, the task
|
|
602
|
+
// text with whatever handoff lines a relay put inside it, and the paths of
|
|
603
|
+
// the attachments this call already wrote.
|
|
604
|
+
const assignment = { ...args, task_id: taskId };
|
|
605
|
+
const text = injectionText({ assign: assignment, proseIsNew, attachmentPaths });
|
|
591
606
|
|
|
592
607
|
const held = this.tasks.get(taskId);
|
|
593
608
|
// A completed record is not a live one: a new envelope for a task that
|
|
@@ -598,9 +613,16 @@ export class OnlyneAgent {
|
|
|
598
613
|
// is: its delivered set keeps the relay guard's count, and its completion
|
|
599
614
|
// state still settles the task. Only the "since this instruction" counter
|
|
600
615
|
// moves, so the settled-without-completing watchdog measures the newest one.
|
|
616
|
+
// The ladder restarts with it: the reminder count, the idle episode it was
|
|
617
|
+
// charged to (`remindedAt`), and the failure an errored turn proved all
|
|
618
|
+
// belong to the instruction being replaced.
|
|
601
619
|
held.turnsSinceAssign = 0;
|
|
620
|
+
held.reminders = 0;
|
|
621
|
+
held.remindedAt = 0;
|
|
622
|
+
held.errored = false;
|
|
602
623
|
held.envelopeId = envelope.id ?? held.envelopeId;
|
|
603
|
-
|
|
624
|
+
held.assignment = assignment;
|
|
625
|
+
held.attachmentPaths = attachmentPaths;
|
|
604
626
|
} else {
|
|
605
627
|
this.tasks.set(taskId, {
|
|
606
628
|
taskId,
|
|
@@ -612,7 +634,12 @@ export class OnlyneAgent {
|
|
|
612
634
|
turns: 0,
|
|
613
635
|
errored: false,
|
|
614
636
|
head: "",
|
|
615
|
-
|
|
637
|
+
assignment,
|
|
638
|
+
attachmentPaths,
|
|
639
|
+
/** Idle reminders sent for this task; the bound is `idleReminders`. */
|
|
640
|
+
reminders: 0,
|
|
641
|
+
/** The `turnsSinceAssign` the last reminder was charged to. */
|
|
642
|
+
remindedAt: 0,
|
|
616
643
|
});
|
|
617
644
|
}
|
|
618
645
|
this.agentState = "running";
|
|
@@ -691,17 +718,18 @@ export class OnlyneAgent {
|
|
|
691
718
|
this.armSettleFallback();
|
|
692
719
|
}
|
|
693
720
|
|
|
694
|
-
/** pi will not continue on its own:
|
|
721
|
+
/** pi will not continue on its own: take the settle decision for this idle. */
|
|
695
722
|
onSettled() {
|
|
696
723
|
this.clearSettleFallback();
|
|
697
724
|
this.trySettle();
|
|
698
725
|
}
|
|
699
726
|
|
|
700
727
|
/**
|
|
701
|
-
* The one
|
|
728
|
+
* The one settle decision. pi keeps `isIdle()` false while it is running,
|
|
702
729
|
* retrying, compacting, or holding a queued continuation, so a settle signal
|
|
703
|
-
* that arrives during any of those waits instead of
|
|
704
|
-
*
|
|
730
|
+
* that arrives during any of those waits instead of deciding on a session
|
|
731
|
+
* that is still busy. The fallback timer and `agent_settled` both land here,
|
|
732
|
+
* and `settleNow` makes the decision idempotent for one idle episode.
|
|
705
733
|
*/
|
|
706
734
|
trySettle() {
|
|
707
735
|
if (this.closed || this.activeTasks().length === 0) return;
|
|
@@ -709,14 +737,13 @@ export class OnlyneAgent {
|
|
|
709
737
|
this.armSettleFallback();
|
|
710
738
|
return;
|
|
711
739
|
}
|
|
712
|
-
void this.settleNow().catch((error) => this.log(`
|
|
740
|
+
void this.settleNow().catch((error) => this.log(`settle decision failed: ${error.message}`));
|
|
713
741
|
}
|
|
714
742
|
|
|
715
|
-
/** A failed turn: the task's outcome is `failed
|
|
743
|
+
/** A failed turn: the task's outcome is `failed`, with the error as its head. */
|
|
716
744
|
onTurnError(text) {
|
|
717
745
|
for (const task of this.tasks.values()) {
|
|
718
746
|
if (task.completed) continue;
|
|
719
|
-
task.failed = true;
|
|
720
747
|
task.errored = true;
|
|
721
748
|
if (text) task.head = headOf(text);
|
|
722
749
|
}
|
|
@@ -724,13 +751,15 @@ export class OnlyneAgent {
|
|
|
724
751
|
}
|
|
725
752
|
|
|
726
753
|
/**
|
|
727
|
-
* The last assistant text seen, kept as the completion summary for a task
|
|
728
|
-
*
|
|
754
|
+
* The last assistant text seen, kept as the completion summary for a task
|
|
755
|
+
* whose `onlyne_complete` call hands no argument over.
|
|
729
756
|
*
|
|
730
757
|
* This is the fallback, never the deliverable: `onlyne_complete`'s `text` is
|
|
731
758
|
* reported byte for byte by `completeFromTool` and is never written back
|
|
732
759
|
* here, so the sentence a turn happened to end on cannot stand in for a
|
|
733
|
-
* payload the tool call carried.
|
|
760
|
+
* payload the tool call carried. Nothing else reads it: an idle that runs out
|
|
761
|
+
* of reminders fails the task and says why, rather than reporting the text of
|
|
762
|
+
* a turn nobody completed.
|
|
734
763
|
*/
|
|
735
764
|
noteAssistantText(text) {
|
|
736
765
|
const flat = headOf(text);
|
|
@@ -756,26 +785,78 @@ export class OnlyneAgent {
|
|
|
756
785
|
}
|
|
757
786
|
|
|
758
787
|
/**
|
|
759
|
-
* The
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
788
|
+
* The idle ladder's decision, taken for every task this session still holds
|
|
789
|
+
* when a settle signal finds the agent idle.
|
|
790
|
+
*
|
|
791
|
+
* An errored turn is proof on its own — pi may skip the clean turn end — so
|
|
792
|
+
* its task is reported `failed` at once, with the error as its head. Every
|
|
793
|
+
* other open task gets one rung of the ladder: the plugin re-sends the
|
|
794
|
+
* assignment (`remind`) and counts it, and the idle that finds the bound
|
|
795
|
+
* already spent reports `failed` and leaves the session.
|
|
796
|
+
*
|
|
797
|
+
* `onlyne_complete` is the only path to `done`. A turn that ends without it
|
|
798
|
+
* leaves the task `idle_waiting` (the design's own word for it), and the
|
|
799
|
+
* answer is the reinforcing prompt the model gets instead of a completion it
|
|
800
|
+
* never claimed.
|
|
801
|
+
*
|
|
802
|
+
* A task whose injected message has not run a turn is left alone at every
|
|
803
|
+
* rung: the reminder would re-send an assignment the model may not have read
|
|
804
|
+
* yet, and failing now would claim work that never happened.
|
|
805
|
+
*
|
|
806
|
+
* A rung belongs to an idle episode, not to a settle signal: `remindedAt`
|
|
807
|
+
* records the `turnsSinceAssign` the last reminder was charged to, so the
|
|
808
|
+
* fallback timer and an `agent_settled` answering the same turn end spend one
|
|
809
|
+
* rung between them.
|
|
765
810
|
*/
|
|
766
811
|
async settleNow() {
|
|
767
812
|
for (const task of [...this.tasks.values()]) {
|
|
768
813
|
if (task.completed) continue;
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
814
|
+
if (task.errored) {
|
|
815
|
+
await this.complete(task.taskId, "failed", task.head);
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if (task.turnsSinceAssign === 0 || task.remindedAt === task.turnsSinceAssign) continue;
|
|
819
|
+
task.remindedAt = task.turnsSinceAssign;
|
|
820
|
+
if (task.reminders < this.idleReminders) {
|
|
821
|
+
this.remind(task);
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
const rungs = `${task.reminders} idle reminder${task.reminders === 1 ? "" : "s"}`;
|
|
825
|
+
await this.complete(task.taskId, "failed", `no completion after ${rungs}`);
|
|
773
826
|
}
|
|
774
827
|
}
|
|
775
828
|
|
|
776
829
|
/**
|
|
777
|
-
*
|
|
778
|
-
*
|
|
830
|
+
* One rung of the idle ladder: hand the assignment back to the model.
|
|
831
|
+
*
|
|
832
|
+
* The reminder is the injection's own text — `injectionText` with the prose
|
|
833
|
+
* flag off, so the role prose already in the session's context is not
|
|
834
|
+
* repeated — under one line saying why it is back. The task text is where a
|
|
835
|
+
* relay carries its handoff lines, so a handoff the model still owes comes
|
|
836
|
+
* back with it.
|
|
837
|
+
*
|
|
838
|
+
* The attachments travel as the paths the injection wrote, never as parts:
|
|
839
|
+
* the images were handed to pi once, and re-attaching them would put the same
|
|
840
|
+
* bytes into the context a second time.
|
|
841
|
+
*
|
|
842
|
+
* @param {any} task a record with `assignment` and `attachmentPaths` retained
|
|
843
|
+
* from `onAssign`
|
|
844
|
+
*/
|
|
845
|
+
remind(task) {
|
|
846
|
+
task.reminders += 1;
|
|
847
|
+
const rung = `reminder ${task.reminders} of ${this.idleReminders}`;
|
|
848
|
+
const text = [
|
|
849
|
+
`[onlyne] your turn ended without a completion exit; this task is still open (${rung}). Call onlyne_complete when it is finished.`,
|
|
850
|
+
"",
|
|
851
|
+
injectionText({ assign: task.assignment, proseIsNew: false, attachmentPaths: task.attachmentPaths }),
|
|
852
|
+
].join("\n");
|
|
853
|
+
this.surface.wakeUser?.(text, []);
|
|
854
|
+
this.notice("out", `${rung} for task ${task.taskId.slice(0, 8)}`);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* `onlyne_complete`: the model's explicit outcome, and the only path to
|
|
859
|
+
* `done`.
|
|
779
860
|
*
|
|
780
861
|
* A non-empty `text` is the completion body: it is what the model handed
|
|
781
862
|
* over, reported verbatim as the ledger's one-line `head`, so the last
|
|
@@ -855,7 +936,7 @@ export class OnlyneAgent {
|
|
|
855
936
|
this.notice("out", `complete ${taskId.slice(0, 8)} ${normalized}${summary ? `: ${summary}` : ""}`);
|
|
856
937
|
if (this.activeTasks().length === 0) {
|
|
857
938
|
if (exitProcess) {
|
|
858
|
-
await this.reportSettled(taskId
|
|
939
|
+
await this.reportSettled(taskId).catch((error) =>
|
|
859
940
|
this.log(`settled observation refused: ${error.message}`),
|
|
860
941
|
);
|
|
861
942
|
}
|
|
@@ -881,25 +962,29 @@ export class OnlyneAgent {
|
|
|
881
962
|
}
|
|
882
963
|
|
|
883
964
|
/**
|
|
884
|
-
* One last observation before the process leaves: the
|
|
885
|
-
*
|
|
965
|
+
* One last observation before the process leaves: the agent dimension at
|
|
966
|
+
* `idle`.
|
|
886
967
|
*
|
|
887
968
|
* The completion settles the row from the tuple the client holds, which still
|
|
888
969
|
* says `running` when the turn that finished was the last report sent, and
|
|
889
970
|
* nothing observes the process afterwards. This report is what makes an
|
|
890
971
|
* exited session read idle. It is skipped when the last beat was already
|
|
891
|
-
* idle — the
|
|
972
|
+
* idle — the agent dimension is already right — and it is a request for
|
|
892
973
|
* the same reason the completion is: the answer is the handover, and a
|
|
893
974
|
* failure here must not stop the exit that the durable completion earned.
|
|
975
|
+
*
|
|
976
|
+
* It carries no completion fact. The outcome belongs to `report.complete`, and
|
|
977
|
+
* the drain the completion opens belongs to the client: an observation states
|
|
978
|
+
* where the agent is, not what the session's intent has done.
|
|
894
979
|
*/
|
|
895
|
-
async reportSettled(taskId
|
|
980
|
+
async reportSettled(taskId) {
|
|
896
981
|
if (!this.connected || this.lastPhase === "idle") return false;
|
|
897
982
|
this.seq += 1;
|
|
898
|
-
await this.request("report",
|
|
983
|
+
await this.request("report", heartbeatReport({
|
|
899
984
|
taskId,
|
|
900
|
-
outcome,
|
|
901
985
|
generation: this.generation,
|
|
902
986
|
seq: this.seq,
|
|
987
|
+
agent: "idle",
|
|
903
988
|
host: this.host,
|
|
904
989
|
}));
|
|
905
990
|
this.stats.reports += 1;
|
|
@@ -917,7 +1002,7 @@ export class OnlyneAgent {
|
|
|
917
1002
|
this.activity.set({ taskId: this.activeTaskId() ?? null, phase: pending.outcome });
|
|
918
1003
|
this.notice("out", `complete ${pending.taskId.slice(0, 8)} ${pending.outcome} flushed after reconnect`);
|
|
919
1004
|
if (pending.exitProcess && this.activeTasks().length === 0) {
|
|
920
|
-
await this.reportSettled(pending.taskId
|
|
1005
|
+
await this.reportSettled(pending.taskId).catch((error) =>
|
|
921
1006
|
this.log(`settled observation refused: ${error.message}`),
|
|
922
1007
|
);
|
|
923
1008
|
this.exitSession(pending.outcome);
|
|
@@ -928,6 +1013,21 @@ export class OnlyneAgent {
|
|
|
928
1013
|
}
|
|
929
1014
|
}
|
|
930
1015
|
|
|
1016
|
+
/**
|
|
1017
|
+
* One inline image read off the path the model named, in the wire's
|
|
1018
|
+
* `ImagePart` shape. `mimeForPath` refuses an extension outside the core's
|
|
1019
|
+
* four mime types, and `imagePart` refuses a payload above the byte ceiling.
|
|
1020
|
+
* @param {string | null | undefined} path
|
|
1021
|
+
*/
|
|
1022
|
+
imageFromPath(path) {
|
|
1023
|
+
if (!path) return null;
|
|
1024
|
+
return imagePart({
|
|
1025
|
+
data: readFileSync(path),
|
|
1026
|
+
mime: mimeForPath(path),
|
|
1027
|
+
name: path.split("/").pop() ?? null,
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
|
|
931
1031
|
/**
|
|
932
1032
|
* `onlyne_send`: submit one envelope.
|
|
933
1033
|
* @param {{ to: string, text?: string, kind?: string, imagePath?: string | null }} input
|
|
@@ -935,11 +1035,7 @@ export class OnlyneAgent {
|
|
|
935
1035
|
async sendFromTool(input) {
|
|
936
1036
|
if (!this.connected) throw new Error("onlyne: client socket is not connected");
|
|
937
1037
|
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
|
-
}
|
|
1038
|
+
const image = this.imageFromPath(input.imagePath);
|
|
943
1039
|
const envelope = sendEnvelope({ from: this.role, to: input.to, kind, text: input.text ?? "", image });
|
|
944
1040
|
const data = await this.request("send", envelope);
|
|
945
1041
|
// Recorded only after the client answered the `send`: a refused envelope was
|
|
@@ -949,6 +1045,36 @@ export class OnlyneAgent {
|
|
|
949
1045
|
return { queued: true, op_id: envelope.op_id ?? null, kind, to: input.to, data };
|
|
950
1046
|
}
|
|
951
1047
|
|
|
1048
|
+
/**
|
|
1049
|
+
* `onlyne_handoff`: hand this session's task on to the next hop of its family.
|
|
1050
|
+
*
|
|
1051
|
+
* The task named in the request is the session's own current one, so the child
|
|
1052
|
+
* the host mints under it is a continuation of the family this session serves:
|
|
1053
|
+
* the host reads the parent's causality, derives the child through
|
|
1054
|
+
* `Causality::child_of`, and answers the child's task id and hop. The client's
|
|
1055
|
+
* own refusal is raised out of here as it arrived.
|
|
1056
|
+
* @param {{ to: string, text?: string, imagePath?: string | null }} input
|
|
1057
|
+
*/
|
|
1058
|
+
async handoffFromTool(input) {
|
|
1059
|
+
if (!this.connected) throw new Error("onlyne: client socket is not connected");
|
|
1060
|
+
const taskId = this.activeTaskId();
|
|
1061
|
+
if (!taskId) throw new Error("onlyne: no task is assigned to this session");
|
|
1062
|
+
const data = await this.request("handoff", {
|
|
1063
|
+
task_id: taskId,
|
|
1064
|
+
to: input.to,
|
|
1065
|
+
text: input.text ?? "",
|
|
1066
|
+
image: this.imageFromPath(input.imagePath),
|
|
1067
|
+
});
|
|
1068
|
+
const answer = data ?? {};
|
|
1069
|
+
return {
|
|
1070
|
+
taskId: answer.task_id ?? null,
|
|
1071
|
+
hop: answer.hop ?? null,
|
|
1072
|
+
queued: answer.queued ?? false,
|
|
1073
|
+
opId: answer.op_id ?? null,
|
|
1074
|
+
to: input.to,
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
|
|
952
1078
|
// ------------------------------------------------------------ attachments
|
|
953
1079
|
|
|
954
1080
|
/**
|