@code-yeongyu/senpi-codemode 2026.10.5 → 2026.10.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +41 -0
- package/README.md +20 -2
- package/package.json +4 -4
- package/src/bridge/kernel-tools-protocol.ts +1 -1
- package/src/bridge/reserved.ts +10 -0
- package/src/bridges/eval-virtual-schemas.ts +79 -0
- package/src/bridges/handle-bridge.ts +72 -0
- package/src/bridges/reserved-dispatch.ts +20 -2
- package/src/bridges/schema-bridge.ts +4 -0
- package/src/completion/handle.ts +69 -0
- package/src/completion/tool-bridge.ts +31 -12
- package/src/config/feature-settings.ts +110 -0
- package/src/config/memory-settings.ts +8 -0
- package/src/config/settings-constants.ts +7 -0
- package/src/config/settings-overrides.ts +72 -0
- package/src/config/settings.ts +35 -78
- package/src/config/unknown-settings.ts +19 -0
- package/src/extension/bridge-dispatch.ts +41 -0
- package/src/extension/bridge-tool-call.ts +41 -0
- package/src/extension/host-facts.ts +18 -0
- package/src/extension/idle-parking-kernel.ts +272 -0
- package/src/extension/kernel-registration.ts +25 -0
- package/src/extension/kernel-tools-probe.ts +24 -0
- package/src/extension/removed-tool-hints.ts +15 -0
- package/src/extension/runtime-factory.ts +3 -0
- package/src/extension/session-manager-contract.ts +3 -0
- package/src/extension/session-manager.ts +58 -58
- package/src/extension/start-recovery.ts +111 -0
- package/src/handles/completion-handles.ts +202 -0
- package/src/handles/handle-args.ts +103 -0
- package/src/handles/handle-registry.ts +164 -0
- package/src/handles/wait.ts +214 -0
- package/src/handles/watch-queue.ts +102 -0
- package/src/index.ts +99 -94
- package/src/kernels/jl/handles.jl +124 -0
- package/src/kernels/jl/kernel.ts +3 -1
- package/src/kernels/jl/prelude.jl +9 -0
- package/src/kernels/jl/runner.jl +160 -1
- package/src/kernels/js/context-manager.ts +1 -1
- package/src/kernels/js/handles.js +93 -0
- package/src/kernels/js/kernel-tools-define.d.ts +6 -0
- package/src/kernels/js/kernel-tools-define.js +3 -1
- package/src/kernels/js/kernel-tools-registry.d.ts +2 -0
- package/src/kernels/js/kernel-tools-registry.js +18 -9
- package/src/kernels/js/kernel-tools-schema.js +6 -2
- package/src/kernels/js/kernel-tools-types.ts +2 -1
- package/src/kernels/js/worker-global-sizes.js +84 -14
- package/src/kernels/js/worker-runtime.js +10 -1
- package/src/kernels/py/host-cell.ts +42 -0
- package/src/kernels/py/kernel-contract.ts +2 -0
- package/src/kernels/py/kernel.ts +48 -4
- package/src/kernels/py/prelude.py +145 -4
- package/src/kernels/rb/handles.rb +97 -0
- package/src/kernels/rb/kernel.ts +3 -1
- package/src/kernels/rb/paths.rb +37 -0
- package/src/kernels/rb/prelude.rb +15 -38
- package/src/kernels/rb/runner.rb +141 -0
- package/src/kernels/shared/kernel-memory-host.ts +14 -6
- package/src/kernels/{js/kernel-tools-host.ts → shared/kernel-tools-pump.ts} +2 -2
- package/src/kernels/shared/process-group-cpu.ts +139 -0
- package/src/kernels/shared/subprocess-contract.ts +3 -0
- package/src/kernels/shared/subprocess-kernel.ts +33 -1
- package/src/kernels/shared/subprocess-startup.ts +90 -0
- package/src/tool/cell-handler.ts +21 -18
- package/src/tool/cell-reserved-dispatch.ts +51 -0
- package/src/tool/eval-tool-options.ts +3 -0
- package/src/tool/run-eval-cell.ts +7 -6
- package/src/tool/types.ts +15 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
import type { HostCellExecutor } from "../../tool/types.ts";
|
|
3
|
+
|
|
4
|
+
type HostCellOutcome = Awaited<ReturnType<HostCellExecutor>>;
|
|
5
|
+
|
|
6
|
+
import type { PendingRun, ResultMessage } from "./kernel-contract.ts";
|
|
7
|
+
import { failedPythonResult } from "./transport.ts";
|
|
8
|
+
|
|
9
|
+
export function runHostCell(
|
|
10
|
+
pending: PendingRun,
|
|
11
|
+
host: HostCellExecutor,
|
|
12
|
+
io: { readonly emit: (message: KernelToHostMessage) => void; readonly settle: (result: ResultMessage) => void },
|
|
13
|
+
): void {
|
|
14
|
+
const abort = new AbortController();
|
|
15
|
+
pending.hostAbort = abort;
|
|
16
|
+
const cellId = pending.input.cellId;
|
|
17
|
+
let finished = false;
|
|
18
|
+
// Output from an executor that was aborted, or after its outcome, belongs to no cell.
|
|
19
|
+
const emit = (message: KernelToHostMessage): void => {
|
|
20
|
+
if (!finished && !abort.signal.aborted) io.emit(message);
|
|
21
|
+
};
|
|
22
|
+
// Calling the executor inside a promise keeps a synchronous throw in this cell instead of failing the queue.
|
|
23
|
+
const done = new Promise<HostCellOutcome>((resolve) => resolve(host({ signal: abort.signal, emit }))).then(
|
|
24
|
+
(outcome): ResultMessage =>
|
|
25
|
+
outcome.ok
|
|
26
|
+
? {
|
|
27
|
+
type: "result",
|
|
28
|
+
cellId,
|
|
29
|
+
ok: true,
|
|
30
|
+
durationMs: 0,
|
|
31
|
+
...(outcome.valueRepr === undefined ? {} : { valueRepr: outcome.valueRepr }),
|
|
32
|
+
}
|
|
33
|
+
: { type: "result", cellId, ok: false, error: outcome.error, durationMs: 0 },
|
|
34
|
+
(error: unknown): ResultMessage =>
|
|
35
|
+
failedPythonResult(cellId, error instanceof Error ? error.message : String(error)),
|
|
36
|
+
);
|
|
37
|
+
pending.hostDone = done;
|
|
38
|
+
void done.then((result) => {
|
|
39
|
+
finished = true;
|
|
40
|
+
if (!abort.signal.aborted) io.settle(result);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -39,4 +39,6 @@ export interface PendingRun {
|
|
|
39
39
|
interruptReason?: string;
|
|
40
40
|
/** Set while an interrupt outcome is pending; resolved once the kernel knows whether state survived. */
|
|
41
41
|
resolveStateRetained?: (retained: boolean) => void;
|
|
42
|
+
hostAbort?: AbortController;
|
|
43
|
+
hostDone?: Promise<ResultMessage>;
|
|
42
44
|
}
|
package/src/kernels/py/kernel.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts";
|
|
|
5
5
|
import { describeExit } from "../shared/kernel-death.ts";
|
|
6
6
|
import { KernelMemoryHost } from "../shared/kernel-memory-host.ts";
|
|
7
7
|
import { KernelPreludeTracker } from "../shared/kernel-prelude-plan.ts";
|
|
8
|
+
import { runHostCell } from "./host-cell.ts";
|
|
8
9
|
import type { PendingRun, PythonKernelRunOptions, PythonKernelStartOptions, ResultMessage } from "./kernel-contract.ts";
|
|
9
10
|
import { pythonStartupHangGuardMs } from "./startup.ts";
|
|
10
11
|
import { failedPythonResult, PythonKernelTransport } from "./transport.ts";
|
|
@@ -104,6 +105,11 @@ export class PythonKernel {
|
|
|
104
105
|
}
|
|
105
106
|
}
|
|
106
107
|
const active = this.#active;
|
|
108
|
+
if (active?.hostAbort && active.interruptReason === undefined) {
|
|
109
|
+
active.interruptReason = reason;
|
|
110
|
+
void this.#stopHostEntry(active, failedPythonResult(active.input.cellId, "Eval interrupted"));
|
|
111
|
+
return { stateRetained: Promise.resolve(true) };
|
|
112
|
+
}
|
|
107
113
|
const transport = this.#transport;
|
|
108
114
|
if (!active || !transport || active.interruptReason !== undefined)
|
|
109
115
|
return { stateRetained: Promise.resolve(true) };
|
|
@@ -201,6 +207,14 @@ export class PythonKernel {
|
|
|
201
207
|
const timeoutMs = pending.input.timeoutMs;
|
|
202
208
|
if (timeoutMs !== undefined)
|
|
203
209
|
pending.timeoutTimer = setTimeout(() => this.#timeoutRun(pending, timeoutMs), timeoutMs);
|
|
210
|
+
const host = pending.input.host;
|
|
211
|
+
if (host !== undefined) {
|
|
212
|
+
runHostCell(pending, host, {
|
|
213
|
+
emit: (message) => (pending.input.onMessage ?? this.#options.onMessage)?.(message),
|
|
214
|
+
settle: (result) => this.#settleRun(pending, result),
|
|
215
|
+
});
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
204
218
|
try {
|
|
205
219
|
this.#transport?.run({
|
|
206
220
|
...pending.input,
|
|
@@ -219,11 +233,40 @@ export class PythonKernel {
|
|
|
219
233
|
|
|
220
234
|
#timeoutRun(pending: PendingRun, timeoutMs: number): void {
|
|
221
235
|
if (this.#active !== pending) return;
|
|
236
|
+
const timedOut = failedPythonResult(pending.input.cellId, `Python kernel timed out after ${timeoutMs}ms`);
|
|
237
|
+
if (pending.hostAbort) {
|
|
238
|
+
void this.#stopHostEntry(pending, timedOut);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
222
241
|
if (this.#transport) void this.#beginRetirement(this.#transport).catch(() => undefined);
|
|
223
|
-
this.#settleRun(
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
242
|
+
this.#settleRun(pending, timedOut);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Aborts a running host entry and settles it only once its executor has stopped (or after the same bound the
|
|
247
|
+
* interpreter gets before escalation), so the next queue entry never overlaps the aborted host work. An
|
|
248
|
+
* executor that finished successfully despite the abort reports that outcome: its work did commit.
|
|
249
|
+
*/
|
|
250
|
+
async #stopHostEntry(pending: PendingRun, stopped: ResultMessage): Promise<void> {
|
|
251
|
+
pending.hostAbort?.abort();
|
|
252
|
+
const done = pending.hostDone;
|
|
253
|
+
let bound: ReturnType<typeof setTimeout> | undefined;
|
|
254
|
+
const outcome =
|
|
255
|
+
done === undefined
|
|
256
|
+
? undefined
|
|
257
|
+
: await Promise.race([
|
|
258
|
+
done,
|
|
259
|
+
new Promise<undefined>((resolve) => {
|
|
260
|
+
bound = setTimeout(() => resolve(undefined), interruptEscalationMs);
|
|
261
|
+
}),
|
|
262
|
+
]);
|
|
263
|
+
if (bound !== undefined) clearTimeout(bound);
|
|
264
|
+
if (outcome?.ok === true) {
|
|
265
|
+
pending.interruptReason = undefined;
|
|
266
|
+
this.#settleRun(pending, outcome);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
this.#settleRun(pending, stopped);
|
|
227
270
|
}
|
|
228
271
|
|
|
229
272
|
async #ensureStarted(): Promise<void> {
|
|
@@ -354,6 +397,7 @@ export class PythonKernel {
|
|
|
354
397
|
}
|
|
355
398
|
|
|
356
399
|
#removePending(pending: PendingRun): void {
|
|
400
|
+
pending.hostAbort?.abort();
|
|
357
401
|
if (pending.timeoutTimer) clearTimeout(pending.timeoutTimer);
|
|
358
402
|
if (pending.escalationTimer) clearTimeout(pending.escalationTimer);
|
|
359
403
|
pending.timeoutTimer = null;
|
|
@@ -18,6 +18,7 @@ import inspect
|
|
|
18
18
|
import io
|
|
19
19
|
import json
|
|
20
20
|
import locale
|
|
21
|
+
import math
|
|
21
22
|
import os
|
|
22
23
|
import re
|
|
23
24
|
import signal
|
|
@@ -48,6 +49,11 @@ EMIT_LOCK = Lock()
|
|
|
48
49
|
RESERVED_AGENT_TOOL = "__agent__"
|
|
49
50
|
RESERVED_OUTPUT_TOOL = "__output__"
|
|
50
51
|
RESERVED_SCHEMA_TOOL = "__schema__"
|
|
52
|
+
RESERVED_WAIT_TOOL = "__wait__"
|
|
53
|
+
RESERVED_HANDLE_STATUS_TOOL = "__handle_status__"
|
|
54
|
+
RESERVED_HANDLE_OUTPUT_TOOL = "__handle_output__"
|
|
55
|
+
RESERVED_HANDLE_SEND_TOOL = "__handle_send__"
|
|
56
|
+
RESERVED_HANDLE_CANCEL_TOOL = "__handle_cancel__"
|
|
51
57
|
TIMEOUT_PAUSE_OP = "timeout-pause"
|
|
52
58
|
TIMEOUT_RESUME_OP = "timeout-resume"
|
|
53
59
|
|
|
@@ -354,7 +360,15 @@ def write(path: str | Path, content: str) -> Path:
|
|
|
354
360
|
return target
|
|
355
361
|
|
|
356
362
|
|
|
357
|
-
|
|
363
|
+
# The bridge is a loopback call: urllib's default opener would route it through a configured proxy
|
|
364
|
+
# (the environment everywhere, the registry on Windows), which a 127.0.0.1 request must never use.
|
|
365
|
+
_BRIDGE_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
366
|
+
# Ordinary bridge calls keep this socket timeout; only `__wait__` passes its own bound (see `_bridge_wait_post`).
|
|
367
|
+
_BRIDGE_SOCKET_TIMEOUT_SECONDS = 60
|
|
368
|
+
_WAIT_SOCKET_GRACE_SECONDS = 30
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def bridge_post(path: str, payload: dict[str, Any], *, socket_timeout: float | None = _BRIDGE_SOCKET_TIMEOUT_SECONDS) -> Any:
|
|
358
372
|
port = CONNECTION.get("port")
|
|
359
373
|
token = CONNECTION.get("token")
|
|
360
374
|
if not isinstance(port, int) or not isinstance(token, str):
|
|
@@ -369,7 +383,7 @@ def bridge_post(path: str, payload: dict[str, Any]) -> Any:
|
|
|
369
383
|
emit_status(TIMEOUT_PAUSE_OP, force=True)
|
|
370
384
|
try:
|
|
371
385
|
try:
|
|
372
|
-
with
|
|
386
|
+
with _BRIDGE_OPENER.open(request, timeout=socket_timeout) as response:
|
|
373
387
|
response_data = response.read()
|
|
374
388
|
except urllib.error.HTTPError as exc:
|
|
375
389
|
response_data = exc.read()
|
|
@@ -495,6 +509,8 @@ def completion(
|
|
|
495
509
|
if schema is not None:
|
|
496
510
|
options["schema"] = schema
|
|
497
511
|
response = bridge_post("/completion", {"prompt": prompt, "opts": options})
|
|
512
|
+
if options.get("handle") is True:
|
|
513
|
+
return handle(response)
|
|
498
514
|
if not isinstance(response, dict):
|
|
499
515
|
return response
|
|
500
516
|
if "value" in response:
|
|
@@ -611,6 +627,129 @@ def agent(
|
|
|
611
627
|
return node
|
|
612
628
|
|
|
613
629
|
|
|
630
|
+
_HANDLE_KINDS = ("agent", "completion", "workpool")
|
|
631
|
+
_WAIT_MODES = ("all", "any", "settled")
|
|
632
|
+
_HANDLE_USAGE = (
|
|
633
|
+
"handle() expects an agent(..., handle=True) record, a workpool, a completion handle, "
|
|
634
|
+
"or a saved {kind, id, run_epoch} reference"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _handle_ref(value: Any) -> dict[str, Any]:
|
|
639
|
+
if isinstance(value, HandleView):
|
|
640
|
+
return dict(value.ref)
|
|
641
|
+
if isinstance(value, Workpool):
|
|
642
|
+
return {"kind": "workpool", "id": value.pool_id, "run_epoch": 0}
|
|
643
|
+
if not isinstance(value, dict):
|
|
644
|
+
raise PreludeTypeError(_HANDLE_USAGE)
|
|
645
|
+
if isinstance(value.get("pool_id"), str):
|
|
646
|
+
return {"kind": "workpool", "id": value["pool_id"], "run_epoch": 0}
|
|
647
|
+
kind = value.get("kind")
|
|
648
|
+
if kind not in _HANDLE_KINDS:
|
|
649
|
+
handle_uri = value.get("handle")
|
|
650
|
+
scheme = handle_uri.split("://", 1)[0] if isinstance(handle_uri, str) else None
|
|
651
|
+
kind = scheme if scheme in _HANDLE_KINDS else None
|
|
652
|
+
identity = value.get("id", value.get("task_id"))
|
|
653
|
+
if kind is None or not isinstance(identity, str) or not identity:
|
|
654
|
+
raise PreludeTypeError(_HANDLE_USAGE)
|
|
655
|
+
run_epoch = value.get("run_epoch", 0 if kind == "workpool" else None)
|
|
656
|
+
if isinstance(run_epoch, bool) or not isinstance(run_epoch, int) or run_epoch < 0:
|
|
657
|
+
raise PreludeTypeError(f"{_HANDLE_USAGE}; run_epoch must be a non-negative integer")
|
|
658
|
+
return {"kind": kind, "id": identity, "run_epoch": run_epoch}
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _handle_call(tool_name: str, args: dict[str, Any]) -> Any:
|
|
662
|
+
return bridge_post("/call", {"callId": f"py-{uuid.uuid4()}", "toolName": tool_name, "args": args})
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _bridge_wait_post(args: dict[str, Any], timeout: float | None) -> Any:
|
|
666
|
+
# A long-lived request: no 60 s socket cap. An explicit timeout bounds it (plus grace for the host's
|
|
667
|
+
# reply); without one only the cell's own end does: SIGINT from the host (cancel, the cell's hard limit,
|
|
668
|
+
# which the eval timeout can raise) interrupts the blocking read, which closes the socket and so the
|
|
669
|
+
# host-side subscription.
|
|
670
|
+
socket_timeout = None if timeout is None else float(timeout) + _WAIT_SOCKET_GRACE_SECONDS
|
|
671
|
+
return bridge_post(
|
|
672
|
+
"/call",
|
|
673
|
+
{"callId": f"py-{uuid.uuid4()}", "toolName": RESERVED_WAIT_TOOL, "args": args},
|
|
674
|
+
socket_timeout=socket_timeout,
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def wait(handles: Any, *, timeout: float | None = None, mode: str = "all") -> Any:
|
|
679
|
+
"""Block until the handles settle; never cancels work. See tool_schema("eval:wait")."""
|
|
680
|
+
if handles is None:
|
|
681
|
+
items: list[Any] = []
|
|
682
|
+
elif isinstance(handles, (list, tuple)):
|
|
683
|
+
items = list(handles)
|
|
684
|
+
else:
|
|
685
|
+
items = [handles]
|
|
686
|
+
if timeout is not None and (
|
|
687
|
+
isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout < 0
|
|
688
|
+
):
|
|
689
|
+
raise PreludeValueError("wait() timeout must be a finite number of seconds >= 0")
|
|
690
|
+
if mode not in _WAIT_MODES:
|
|
691
|
+
raise PreludeValueError("wait() mode must be 'all', 'any' or 'settled'")
|
|
692
|
+
args: dict[str, Any] = {"refs": [_handle_ref(item) for item in items], "mode": mode}
|
|
693
|
+
if timeout is not None:
|
|
694
|
+
args["timeout"] = timeout
|
|
695
|
+
return _bridge_wait_post(args, timeout)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
class HandleControl:
|
|
699
|
+
"""Epoch-fenced control over one handle; every call goes through the host capability."""
|
|
700
|
+
|
|
701
|
+
__slots__ = ("_ref",)
|
|
702
|
+
|
|
703
|
+
def __init__(self, ref: dict[str, Any]) -> None:
|
|
704
|
+
self._ref = dict(ref)
|
|
705
|
+
|
|
706
|
+
def __repr__(self) -> str:
|
|
707
|
+
return f"<handle.control {self._ref['kind']}://{self._ref['id']}@{self._ref['run_epoch']}>"
|
|
708
|
+
|
|
709
|
+
def status(self) -> Any:
|
|
710
|
+
return _handle_call(RESERVED_HANDLE_STATUS_TOOL, {"ref": self._ref})
|
|
711
|
+
|
|
712
|
+
def output(self, format: str = "raw", offset: int | None = None, limit: int | None = None) -> Any:
|
|
713
|
+
args: dict[str, Any] = {"ref": self._ref, "format": format}
|
|
714
|
+
if offset is not None:
|
|
715
|
+
args["offset"] = offset
|
|
716
|
+
if limit is not None:
|
|
717
|
+
args["limit"] = limit
|
|
718
|
+
return _handle_call(RESERVED_HANDLE_OUTPUT_TOOL, args)
|
|
719
|
+
|
|
720
|
+
def send(self, message: str) -> Any:
|
|
721
|
+
return _handle_call(RESERVED_HANDLE_SEND_TOOL, {"ref": self._ref, "message": str(message)})
|
|
722
|
+
|
|
723
|
+
def cancel(self) -> Any:
|
|
724
|
+
return _handle_call(RESERVED_HANDLE_CANCEL_TOOL, {"ref": self._ref})
|
|
725
|
+
|
|
726
|
+
def wait(self, timeout: float | None = None) -> Any:
|
|
727
|
+
return wait([self._ref], timeout=timeout, mode="all")[0]
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
class HandleView(dict):
|
|
731
|
+
"""The legacy record's fields as a dict, plus `control` and `ref` as attributes (never keys)."""
|
|
732
|
+
|
|
733
|
+
__slots__ = ("control", "ref")
|
|
734
|
+
|
|
735
|
+
def __init__(self, fields: dict[str, Any], ref: dict[str, Any]) -> None:
|
|
736
|
+
super().__init__(fields)
|
|
737
|
+
self.ref = dict(ref)
|
|
738
|
+
self.control = HandleControl(ref)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def handle(value: Any) -> HandleView:
|
|
742
|
+
"""Rich view of an agent record, workpool, completion handle or saved reference; see tool_schema("eval:helpers")."""
|
|
743
|
+
ref = _handle_ref(value)
|
|
744
|
+
fields: dict[str, Any] = {}
|
|
745
|
+
if isinstance(value, dict):
|
|
746
|
+
fields.update({key: item for key, item in value.items() if not callable(item)})
|
|
747
|
+
fields.setdefault("id", ref["id"])
|
|
748
|
+
fields.setdefault("run_epoch", ref["run_epoch"])
|
|
749
|
+
fields.setdefault("handle", f"{ref['kind']}://{ref['id']}")
|
|
750
|
+
return HandleView(fields, ref)
|
|
751
|
+
|
|
752
|
+
|
|
614
753
|
def _pool_map(items: Iterable[Any], function: Callable[[Any], Any]) -> list[Any]:
|
|
615
754
|
values = list(items)
|
|
616
755
|
if not values:
|
|
@@ -992,6 +1131,8 @@ USER_NS.update(
|
|
|
992
1131
|
"workpool": workpool,
|
|
993
1132
|
"output": output,
|
|
994
1133
|
"tool_schema": tool_schema,
|
|
1134
|
+
"wait": wait,
|
|
1135
|
+
"handle": handle,
|
|
995
1136
|
"__senpi_magic": _magic,
|
|
996
1137
|
"__senpi_magic_cell": _magic_cell,
|
|
997
1138
|
"__senpi_shell": _shell,
|
|
@@ -1400,7 +1541,7 @@ def elapsed(start: float) -> int:
|
|
|
1400
1541
|
return max(0, int((time.monotonic() - start) * 1000))
|
|
1401
1542
|
|
|
1402
1543
|
|
|
1403
|
-
def
|
|
1544
|
+
def _handle_message(message: dict[str, Any]) -> bool:
|
|
1404
1545
|
global CONNECTION
|
|
1405
1546
|
message_type = message.get("type")
|
|
1406
1547
|
if message_type == "init":
|
|
@@ -1478,7 +1619,7 @@ def main() -> None:
|
|
|
1478
1619
|
host_closed = False
|
|
1479
1620
|
for raw in sys.stdin:
|
|
1480
1621
|
try:
|
|
1481
|
-
if not
|
|
1622
|
+
if not _handle_message(json.loads(raw)):
|
|
1482
1623
|
host_closed = True
|
|
1483
1624
|
break
|
|
1484
1625
|
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — process boundary serializes malformed input and interrupts.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# In-cell wait()/handle() over the host's handle capability. Mirrors src/bridge/reserved.ts.
|
|
2
|
+
SENPI_RESERVED_WAIT_TOOL = "__wait__"
|
|
3
|
+
SENPI_RESERVED_HANDLE_STATUS_TOOL = "__handle_status__"
|
|
4
|
+
SENPI_RESERVED_HANDLE_OUTPUT_TOOL = "__handle_output__"
|
|
5
|
+
SENPI_RESERVED_HANDLE_SEND_TOOL = "__handle_send__"
|
|
6
|
+
SENPI_RESERVED_HANDLE_CANCEL_TOOL = "__handle_cancel__"
|
|
7
|
+
SENPI_HANDLE_KINDS = ["agent", "completion", "workpool"].freeze
|
|
8
|
+
SENPI_WAIT_MODES = ["all", "any", "settled"].freeze
|
|
9
|
+
SENPI_WAIT_SOCKET_GRACE_SECONDS = 30
|
|
10
|
+
SENPI_HANDLE_USAGE = "handle() expects an agent(..., handle: true) record, a workpool, a completion handle, or a saved {kind, id, run_epoch} reference"
|
|
11
|
+
|
|
12
|
+
def __senpi_handle_ref(value)
|
|
13
|
+
return value.ref.dup if value.respond_to?(:ref) && value.respond_to?(:control)
|
|
14
|
+
return { "kind" => "workpool", "id" => value.pool_id, "run_epoch" => 0 } if value.is_a?(SenpiWorkpool)
|
|
15
|
+
raise TypeError, SENPI_HANDLE_USAGE unless value.is_a?(Hash)
|
|
16
|
+
record = value.transform_keys(&:to_s)
|
|
17
|
+
return { "kind" => "workpool", "id" => record["pool_id"], "run_epoch" => 0 } if record["pool_id"].is_a?(String)
|
|
18
|
+
kind = record["kind"]
|
|
19
|
+
unless SENPI_HANDLE_KINDS.include?(kind)
|
|
20
|
+
scheme = record["handle"].is_a?(String) ? record["handle"].split("://", 2).first : nil
|
|
21
|
+
kind = SENPI_HANDLE_KINDS.include?(scheme) ? scheme : nil
|
|
22
|
+
end
|
|
23
|
+
identity = record.fetch("id", record["task_id"])
|
|
24
|
+
raise TypeError, SENPI_HANDLE_USAGE if kind.nil? || !identity.is_a?(String) || identity.empty?
|
|
25
|
+
run_epoch = record.fetch("run_epoch", kind == "workpool" ? 0 : nil)
|
|
26
|
+
raise TypeError, "#{SENPI_HANDLE_USAGE}; run_epoch must be a non-negative integer" unless run_epoch.is_a?(Integer) && run_epoch >= 0
|
|
27
|
+
{ "kind" => kind, "id" => identity, "run_epoch" => run_epoch }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A long-lived request: ordinary calls keep their 60 s read timeout; this one is bounded by the explicit
|
|
31
|
+
# timeout (plus grace for the host's reply) or else only by the cell's own end: SIGINT from the host (cancel,
|
|
32
|
+
# the cell's hard limit) interrupts the read, which closes the socket and the host-side subscription.
|
|
33
|
+
def __senpi_wait_post(args, timeout)
|
|
34
|
+
read_timeout = timeout.nil? ? nil : timeout.to_f + SENPI_WAIT_SOCKET_GRACE_SECONDS
|
|
35
|
+
payload = { "callId" => "rb-#{Process.pid}-#{rand(1_000_000)}", "toolName" => SENPI_RESERVED_WAIT_TOOL, "args" => args }
|
|
36
|
+
__senpi_bridge_request("/call", payload, read_timeout: read_timeout)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def __senpi_wait(handles, timeout: nil, mode: "all")
|
|
40
|
+
items = handles.nil? ? [] : handles.is_a?(Array) ? handles : [handles]
|
|
41
|
+
unless timeout.nil? || (timeout.is_a?(Numeric) && timeout.to_f.finite? && timeout >= 0)
|
|
42
|
+
raise ArgumentError, "wait() timeout must be a finite number of seconds >= 0"
|
|
43
|
+
end
|
|
44
|
+
raise ArgumentError, "wait() mode must be 'all', 'any' or 'settled'" unless SENPI_WAIT_MODES.include?(mode.to_s)
|
|
45
|
+
args = { "refs" => items.map { |item| __senpi_handle_ref(item) }, "mode" => mode.to_s }
|
|
46
|
+
args["timeout"] = timeout unless timeout.nil?
|
|
47
|
+
__senpi_wait_post(args, timeout)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class SenpiHandleControl
|
|
51
|
+
def initialize(ref)
|
|
52
|
+
@ref = ref.dup.freeze
|
|
53
|
+
freeze
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def status
|
|
57
|
+
__senpi_call_tool(SENPI_RESERVED_HANDLE_STATUS_TOOL, { "ref" => @ref })
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def output(format: "raw", offset: nil, limit: nil)
|
|
61
|
+
args = { "ref" => @ref, "format" => format }
|
|
62
|
+
args["offset"] = offset unless offset.nil?
|
|
63
|
+
args["limit"] = limit unless limit.nil?
|
|
64
|
+
__senpi_call_tool(SENPI_RESERVED_HANDLE_OUTPUT_TOOL, args)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def send_message(message)
|
|
68
|
+
__senpi_call_tool(SENPI_RESERVED_HANDLE_SEND_TOOL, { "ref" => @ref, "message" => message.to_s })
|
|
69
|
+
end
|
|
70
|
+
# Deliberately shadows Object#send so control.send(msg) matches the other languages; use __send__ for reflection.
|
|
71
|
+
alias send send_message
|
|
72
|
+
|
|
73
|
+
def cancel
|
|
74
|
+
__senpi_call_tool(SENPI_RESERVED_HANDLE_CANCEL_TOOL, { "ref" => @ref })
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def wait(timeout: nil)
|
|
78
|
+
__senpi_wait([@ref], timeout: timeout, mode: "all")[0]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def inspect
|
|
82
|
+
"#<handle.control #{@ref["kind"]}://#{@ref["id"]}@#{@ref["run_epoch"]}>"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The legacy record's fields as a Hash copy; `control` and `ref` are singleton methods, never keys.
|
|
87
|
+
def __senpi_handle_view(value)
|
|
88
|
+
ref = __senpi_handle_ref(value)
|
|
89
|
+
view = value.is_a?(Hash) ? value.transform_keys(&:to_s).reject { |_key, item| item.respond_to?(:call) } : {}
|
|
90
|
+
view["id"] ||= ref["id"]
|
|
91
|
+
view["run_epoch"] ||= ref["run_epoch"]
|
|
92
|
+
view["handle"] ||= "#{ref["kind"]}://#{ref["id"]}"
|
|
93
|
+
control = SenpiHandleControl.new(ref)
|
|
94
|
+
view.define_singleton_method(:control) { control }
|
|
95
|
+
view.define_singleton_method(:ref) { ref.dup }
|
|
96
|
+
view
|
|
97
|
+
end
|
package/src/kernels/rb/kernel.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { join } from "node:path";
|
|
|
2
2
|
import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
3
|
import type { SessionEnvironment } from "../session-env.ts";
|
|
4
4
|
import type { KernelLifecycle } from "../shared/kernel-death.ts";
|
|
5
|
+
import { readProcessGroupCpuTime } from "../shared/process-group-cpu.ts";
|
|
5
6
|
import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
|
|
6
7
|
import { SubprocessKernel, type SubprocessKernelMemory, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
|
|
7
8
|
|
|
@@ -14,7 +15,7 @@ export interface RubyKernelStartOptions extends KernelLifecycle {
|
|
|
14
15
|
readonly command?: string;
|
|
15
16
|
readonly spawn?: SubprocessSpawn;
|
|
16
17
|
readonly onMessage?: (message: KernelToHostMessage) => void;
|
|
17
|
-
/**
|
|
18
|
+
/** Memory management: the runner reports its largest globals and the host reads the interpreter footprint for the ceiling. */
|
|
18
19
|
readonly memory?: SubprocessKernelMemory;
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -43,6 +44,7 @@ export class RubyKernel extends SubprocessKernel {
|
|
|
43
44
|
onMessage: options.onMessage,
|
|
44
45
|
memory: options.memory && { language: "rb", ...options.memory },
|
|
45
46
|
onDeath: options.onDeath,
|
|
47
|
+
startup: { label: "Ruby", readGroupCpuTime: readProcessGroupCpuTime },
|
|
46
48
|
});
|
|
47
49
|
}
|
|
48
50
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Path resolution (local:// roots) and display payload shaping for the Ruby prelude helpers.
|
|
2
|
+
def __senpi_resolve_path(value)
|
|
3
|
+
raw = value.to_s
|
|
4
|
+
match = SENPI_INTERNAL_URL.match(raw)
|
|
5
|
+
return File.expand_path(raw) unless match
|
|
6
|
+
|
|
7
|
+
scheme = match[1].downcase
|
|
8
|
+
roots = $__senpi_connection.is_a?(Hash) ? $__senpi_connection["localRoots"] : nil
|
|
9
|
+
root = roots[scheme] if roots.is_a?(Hash)
|
|
10
|
+
raise "Protocol paths are not supported by this helper: #{raw}" unless root.is_a?(String) && !root.empty?
|
|
11
|
+
|
|
12
|
+
relative = URI::DEFAULT_PARSER.unescape(match[2].tr("\\", "/"))
|
|
13
|
+
root_path = File.expand_path(root)
|
|
14
|
+
return root_path if relative.empty?
|
|
15
|
+
if relative.start_with?("/") || relative.split("/").include?("..")
|
|
16
|
+
raise "Unsafe #{scheme}:// path (absolute or traversal): #{raw}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
resolved = File.expand_path(relative, root_path)
|
|
20
|
+
unless resolved == root_path || resolved.start_with?(root_path + File::SEPARATOR)
|
|
21
|
+
raise "#{scheme}:// path escapes its root: #{raw}"
|
|
22
|
+
end
|
|
23
|
+
resolved
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def __senpi_display_payload(value)
|
|
27
|
+
if value.is_a?(Hash)
|
|
28
|
+
kind = value["type"] || value[:type]
|
|
29
|
+
text_value = value["text"] || value[:text]
|
|
30
|
+
return ["text/markdown", text_value.to_s] if kind == "markdown" && !text_value.nil?
|
|
31
|
+
return ["image/png", value["data"].to_s] if kind == "image" && value["mimeType"] == "image/png"
|
|
32
|
+
return ["image/jpeg", value["data"].to_s] if kind == "image" && value["mimeType"] == "image/jpeg"
|
|
33
|
+
return ["application/json", JSON.generate(value)]
|
|
34
|
+
end
|
|
35
|
+
return ["application/json", JSON.generate(value)] if value.is_a?(Array)
|
|
36
|
+
["text/plain", value.to_s]
|
|
37
|
+
end
|
|
@@ -19,42 +19,7 @@ def __senpi_emit_status(op, fields = {}, force: false)
|
|
|
19
19
|
__senpi_emit({ "type" => "status", "event" => { "op" => op }.merge(fields.transform_keys(&:to_s)) })
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
raw = value.to_s
|
|
24
|
-
match = SENPI_INTERNAL_URL.match(raw)
|
|
25
|
-
return File.expand_path(raw) unless match
|
|
26
|
-
|
|
27
|
-
scheme = match[1].downcase
|
|
28
|
-
roots = $__senpi_connection.is_a?(Hash) ? $__senpi_connection["localRoots"] : nil
|
|
29
|
-
root = roots[scheme] if roots.is_a?(Hash)
|
|
30
|
-
raise "Protocol paths are not supported by this helper: #{raw}" unless root.is_a?(String) && !root.empty?
|
|
31
|
-
|
|
32
|
-
relative = URI::DEFAULT_PARSER.unescape(match[2].tr("\\", "/"))
|
|
33
|
-
root_path = File.expand_path(root)
|
|
34
|
-
return root_path if relative.empty?
|
|
35
|
-
if relative.start_with?("/") || relative.split("/").include?("..")
|
|
36
|
-
raise "Unsafe #{scheme}:// path (absolute or traversal): #{raw}"
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
resolved = File.expand_path(relative, root_path)
|
|
40
|
-
unless resolved == root_path || resolved.start_with?(root_path + File::SEPARATOR)
|
|
41
|
-
raise "#{scheme}:// path escapes its root: #{raw}"
|
|
42
|
-
end
|
|
43
|
-
resolved
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def __senpi_display_payload(value)
|
|
47
|
-
if value.is_a?(Hash)
|
|
48
|
-
kind = value["type"] || value[:type]
|
|
49
|
-
text_value = value["text"] || value[:text]
|
|
50
|
-
return ["text/markdown", text_value.to_s] if kind == "markdown" && !text_value.nil?
|
|
51
|
-
return ["image/png", value["data"].to_s] if kind == "image" && value["mimeType"] == "image/png"
|
|
52
|
-
return ["image/jpeg", value["data"].to_s] if kind == "image" && value["mimeType"] == "image/jpeg"
|
|
53
|
-
return ["application/json", JSON.generate(value)]
|
|
54
|
-
end
|
|
55
|
-
return ["application/json", JSON.generate(value)] if value.is_a?(Array)
|
|
56
|
-
["text/plain", value.to_s]
|
|
57
|
-
end
|
|
22
|
+
require_relative "paths"
|
|
58
23
|
|
|
59
24
|
def display(value)
|
|
60
25
|
mime_type, payload = __senpi_display_payload(value)
|
|
@@ -118,7 +83,7 @@ def env(key = nil, value = nil)
|
|
|
118
83
|
resolved
|
|
119
84
|
end
|
|
120
85
|
|
|
121
|
-
def __senpi_bridge_request(path, payload)
|
|
86
|
+
def __senpi_bridge_request(path, payload, read_timeout: 60)
|
|
122
87
|
connection = $__senpi_connection
|
|
123
88
|
raise "Ruby tool bridge is not initialized" unless connection.is_a?(Hash)
|
|
124
89
|
port = connection["port"]
|
|
@@ -132,7 +97,7 @@ def __senpi_bridge_request(path, payload)
|
|
|
132
97
|
request.body = JSON.generate(payload)
|
|
133
98
|
__senpi_emit_status("timeout-pause", force: true)
|
|
134
99
|
begin
|
|
135
|
-
response = Net::HTTP.start(uri.hostname, uri.port, open_timeout: 10, read_timeout:
|
|
100
|
+
response = Net::HTTP.start(uri.hostname, uri.port, open_timeout: 10, read_timeout: read_timeout) { |http| http.request(request) }
|
|
136
101
|
ensure
|
|
137
102
|
__senpi_emit_status("timeout-resume", force: true)
|
|
138
103
|
end
|
|
@@ -187,12 +152,24 @@ def tool
|
|
|
187
152
|
end
|
|
188
153
|
|
|
189
154
|
require_relative "workpool"
|
|
155
|
+
require_relative "handles"
|
|
156
|
+
|
|
157
|
+
# Block until the handles settle; never cancels work. See tool_schema("eval:wait").
|
|
158
|
+
def wait(handles, timeout: nil, mode: "all")
|
|
159
|
+
__senpi_wait(handles, timeout: timeout, mode: mode)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Rich view of an agent record, workpool, completion handle or saved reference; see tool_schema("eval:helpers").
|
|
163
|
+
def handle(value)
|
|
164
|
+
__senpi_handle_view(value)
|
|
165
|
+
end
|
|
190
166
|
|
|
191
167
|
def completion(prompt, model: "default", system: nil, schema: nil, **kwargs)
|
|
192
168
|
options = { "model" => model }.merge(kwargs.transform_keys(&:to_s))
|
|
193
169
|
options["system"] = system unless system.nil?
|
|
194
170
|
options["schema"] = schema unless schema.nil?
|
|
195
171
|
result = __senpi_bridge_request("/completion", { "prompt" => prompt.to_s, "opts" => options })
|
|
172
|
+
return handle(result) if options["handle"] == true
|
|
196
173
|
return result unless result.is_a?(Hash)
|
|
197
174
|
return result["value"] if result.key?("value")
|
|
198
175
|
|