@code-yeongyu/senpi-codemode 2026.10.6 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +4 -0
  3. package/package.json +4 -4
  4. package/src/bridge/reserved.ts +10 -0
  5. package/src/bridges/eval-virtual-schemas.ts +79 -0
  6. package/src/bridges/handle-bridge.ts +72 -0
  7. package/src/bridges/reserved-dispatch.ts +20 -2
  8. package/src/bridges/schema-bridge.ts +4 -0
  9. package/src/completion/handle.ts +69 -0
  10. package/src/completion/tool-bridge.ts +31 -12
  11. package/src/extension/bridge-dispatch.ts +41 -0
  12. package/src/extension/bridge-tool-call.ts +5 -1
  13. package/src/extension/host-facts.ts +18 -0
  14. package/src/extension/removed-tool-hints.ts +15 -0
  15. package/src/extension/runtime-factory.ts +3 -0
  16. package/src/extension/session-manager-contract.ts +3 -0
  17. package/src/extension/session-manager.ts +17 -4
  18. package/src/extension/start-recovery.ts +111 -0
  19. package/src/handles/completion-handles.ts +202 -0
  20. package/src/handles/handle-args.ts +103 -0
  21. package/src/handles/handle-registry.ts +164 -0
  22. package/src/handles/wait.ts +214 -0
  23. package/src/handles/watch-queue.ts +102 -0
  24. package/src/index.ts +99 -94
  25. package/src/kernels/jl/handles.jl +124 -0
  26. package/src/kernels/jl/prelude.jl +9 -0
  27. package/src/kernels/jl/runner.jl +6 -1
  28. package/src/kernels/js/handles.js +93 -0
  29. package/src/kernels/js/kernel-tools-define.d.ts +6 -0
  30. package/src/kernels/js/kernel-tools-define.js +3 -1
  31. package/src/kernels/js/kernel-tools-registry.d.ts +2 -0
  32. package/src/kernels/js/kernel-tools-registry.js +18 -9
  33. package/src/kernels/js/kernel-tools-schema.js +6 -2
  34. package/src/kernels/js/worker-runtime.js +10 -1
  35. package/src/kernels/py/host-cell.ts +42 -0
  36. package/src/kernels/py/kernel-contract.ts +2 -0
  37. package/src/kernels/py/kernel.ts +48 -4
  38. package/src/kernels/py/prelude.py +140 -4
  39. package/src/kernels/rb/handles.rb +97 -0
  40. package/src/kernels/rb/paths.rb +37 -0
  41. package/src/kernels/rb/prelude.rb +15 -38
  42. package/src/kernels/rb/runner.rb +7 -7
  43. package/src/tool/cell-handler.ts +21 -18
  44. package/src/tool/cell-reserved-dispatch.ts +51 -0
  45. package/src/tool/eval-tool-options.ts +3 -0
  46. package/src/tool/run-eval-cell.ts +2 -0
  47. package/src/tool/types.ts +15 -0
@@ -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
 
@@ -357,9 +363,12 @@ def write(path: str | Path, content: str) -> Path:
357
363
  # The bridge is a loopback call: urllib's default opener would route it through a configured proxy
358
364
  # (the environment everywhere, the registry on Windows), which a 127.0.0.1 request must never use.
359
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
360
369
 
361
370
 
362
- def bridge_post(path: str, payload: dict[str, Any]) -> Any:
371
+ def bridge_post(path: str, payload: dict[str, Any], *, socket_timeout: float | None = _BRIDGE_SOCKET_TIMEOUT_SECONDS) -> Any:
363
372
  port = CONNECTION.get("port")
364
373
  token = CONNECTION.get("token")
365
374
  if not isinstance(port, int) or not isinstance(token, str):
@@ -374,7 +383,7 @@ def bridge_post(path: str, payload: dict[str, Any]) -> Any:
374
383
  emit_status(TIMEOUT_PAUSE_OP, force=True)
375
384
  try:
376
385
  try:
377
- with _BRIDGE_OPENER.open(request, timeout=60) as response:
386
+ with _BRIDGE_OPENER.open(request, timeout=socket_timeout) as response:
378
387
  response_data = response.read()
379
388
  except urllib.error.HTTPError as exc:
380
389
  response_data = exc.read()
@@ -500,6 +509,8 @@ def completion(
500
509
  if schema is not None:
501
510
  options["schema"] = schema
502
511
  response = bridge_post("/completion", {"prompt": prompt, "opts": options})
512
+ if options.get("handle") is True:
513
+ return handle(response)
503
514
  if not isinstance(response, dict):
504
515
  return response
505
516
  if "value" in response:
@@ -616,6 +627,129 @@ def agent(
616
627
  return node
617
628
 
618
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
+
619
753
  def _pool_map(items: Iterable[Any], function: Callable[[Any], Any]) -> list[Any]:
620
754
  values = list(items)
621
755
  if not values:
@@ -997,6 +1131,8 @@ USER_NS.update(
997
1131
  "workpool": workpool,
998
1132
  "output": output,
999
1133
  "tool_schema": tool_schema,
1134
+ "wait": wait,
1135
+ "handle": handle,
1000
1136
  "__senpi_magic": _magic,
1001
1137
  "__senpi_magic_cell": _magic_cell,
1002
1138
  "__senpi_shell": _shell,
@@ -1405,7 +1541,7 @@ def elapsed(start: float) -> int:
1405
1541
  return max(0, int((time.monotonic() - start) * 1000))
1406
1542
 
1407
1543
 
1408
- def handle(message: dict[str, Any]) -> bool:
1544
+ def _handle_message(message: dict[str, Any]) -> bool:
1409
1545
  global CONNECTION
1410
1546
  message_type = message.get("type")
1411
1547
  if message_type == "init":
@@ -1483,7 +1619,7 @@ def main() -> None:
1483
1619
  host_closed = False
1484
1620
  for raw in sys.stdin:
1485
1621
  try:
1486
- if not handle(json.loads(raw)):
1622
+ if not _handle_message(json.loads(raw)):
1487
1623
  host_closed = True
1488
1624
  break
1489
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
@@ -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
- def __senpi_resolve_path(value)
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: 60) { |http| http.request(request) }
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
 
@@ -165,27 +165,27 @@ class SenpiGlobalSizer
165
165
  return ObjectSpace.memsize_of(value) if Integer === value || Float === value
166
166
  return 0 if @seen.key?(value)
167
167
  @seen[value] = true
168
- return SENPI_SIZER_OBJECT + SENPI_STRING_BYTESIZE.bind_call(value) if String === value
168
+ return SENPI_SIZER_OBJECT + SENPI_STRING_BYTESIZE.bind(value).call() if String === value
169
169
  if depth >= SENPI_SIZER_MAX_DEPTH || @nodes >= SENPI_SIZER_NODE_BUDGET
170
170
  @approximate = true
171
171
  return ObjectSpace.memsize_of(value)
172
172
  end
173
173
  if Array === value
174
- length = SENPI_ARRAY_SIZE.bind_call(value)
175
- SENPI_SIZER_OBJECT + length * SENPI_SIZER_POINTER + sampled(length, depth) { |index| SENPI_ARRAY_AT.bind_call(value, index) }
174
+ length = SENPI_ARRAY_SIZE.bind(value).call()
175
+ SENPI_SIZER_OBJECT + length * SENPI_SIZER_POINTER + sampled(length, depth) { |index| SENPI_ARRAY_AT.bind(value).call(index) }
176
176
  elsif Hash === value
177
177
  hash_size(value, depth)
178
178
  else
179
- ivars = SENPI_IVARS.bind_call(value)
180
- ObjectSpace.memsize_of(value) + sampled(ivars.length, depth) { |index| SENPI_IVAR_GET.bind_call(value, ivars[index]) }
179
+ ivars = SENPI_IVARS.bind(value).call()
180
+ ObjectSpace.memsize_of(value) + sampled(ivars.length, depth) { |index| SENPI_IVAR_GET.bind(value).call(ivars[index]) }
181
181
  end
182
182
  end
183
183
 
184
184
  def hash_size(hash, depth)
185
- count = SENPI_HASH_SIZE.bind_call(hash)
185
+ count = SENPI_HASH_SIZE.bind(hash).call()
186
186
  taken = 0
187
187
  total = 0
188
- SENPI_HASH_EACH.bind_call(hash) do |key, item|
188
+ SENPI_HASH_EACH.bind(hash).call() do |key, item|
189
189
  total += size(key, depth + 1) + size(item, depth + 1)
190
190
  taken += 1
191
191
  break if taken >= SENPI_SIZER_SAMPLE
@@ -14,6 +14,7 @@ import { appendSchemaHint } from "../bridges/schema-hint.ts";
14
14
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
15
15
  import { handleCompletionToolCall } from "../completion/tool-bridge.ts";
16
16
  import type { ResolvedCodemodeSettings } from "../config/settings.ts";
17
+ import type { HandleRegistry } from "../handles/handle-registry.ts";
17
18
  import type { KernelToolsCapability } from "../kernels/js/kernel-tools-types.ts";
18
19
  import {
19
20
  boundToolCallArgs,
@@ -24,6 +25,7 @@ import {
24
25
  type ToolCallCapture,
25
26
  toolCallResultPreview,
26
27
  } from "./call-capture.ts";
28
+ import { completionCallOptions, reservedDispatchContext } from "./cell-reserved-dispatch.ts";
27
29
  import { CellResultBuilder, type CellState } from "./cell-runtime.ts";
28
30
  import { type EvalImageResizer, marshalToolResult, toolResultIsError } from "./image.ts";
29
31
  import { upsertStatusEvent } from "./status-events.ts";
@@ -48,6 +50,10 @@ export interface CellBridgeRuntime {
48
50
  readonly imageResizer?: EvalImageResizer;
49
51
  /** This cell's live kernel-tool capability; only a JS kernel has one (#1754). */
50
52
  readonly kernelTools?: KernelToolsCapability;
53
+ /** The session generation's handle registry behind `wait()` / `handle()` / completion handles. */
54
+ readonly handles?: HandleRegistry;
55
+ /** Absolute wall-clock deadline of this cell; a completion handle it creates is bounded by it. */
56
+ readonly hardDeadlineMs?: number;
51
57
  }
52
58
 
53
59
  export class CellHandler {
@@ -159,17 +165,12 @@ export class CellHandler {
159
165
  await this.#deliverToolReply(
160
166
  message,
161
167
  async () => ({
162
- value: await runReservedTool(message.toolName, {
163
- callId: message.callId,
164
- args: message.args,
165
- executeTool: this.#runtime.executeTool,
166
- taskToolName: this.#runtime.settings.taskTools.task,
167
- taskOutputToolName: this.#runtime.settings.taskTools.output,
168
- listTools: this.#runtime.listTools,
169
- signal: this.#state.signal,
170
- emitStatus: (event) => this.#recordStatus(event),
171
- marshalToolResult,
172
- }),
168
+ value: await runReservedTool(
169
+ message.toolName,
170
+ reservedDispatchContext(message, this.#runtime, this.#state.signal, (event) =>
171
+ this.#recordStatus(event),
172
+ ),
173
+ ),
173
174
  toolCallOk: true,
174
175
  }),
175
176
  capture,
@@ -177,13 +178,15 @@ export class CellHandler {
177
178
  return;
178
179
  }
179
180
  if (message.toolName === "completion" && this.#runtime.complete) {
180
- const result = await handleCompletionToolCall({
181
- message,
182
- kernel: this.#kernel,
183
- complete: this.#runtime.complete,
184
- ctx: this.#runtime.ctx,
185
- isActive: () => this.#state.active,
186
- });
181
+ const result = await handleCompletionToolCall(
182
+ completionCallOptions(
183
+ message,
184
+ this.#kernel,
185
+ this.#runtime,
186
+ this.#runtime.complete,
187
+ () => this.#state.active,
188
+ ),
189
+ );
187
190
  if (!this.#state.active) return;
188
191
  recordToolCall(this.#state.toolCalls, result.ok, capture, undefined, result.ok ? undefined : result.error);
189
192
  this.#resultBuilder.emitUpdate(false);
@@ -0,0 +1,51 @@
1
+ import type { ExtensionContext } from "@code-yeongyu/senpi";
2
+ import type { KernelToHostMessage } from "../bridge/protocol.ts";
3
+ import type { ReservedDispatchContext } from "../bridges/reserved-dispatch.ts";
4
+ import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
5
+ import type { CompletionToolCallOptions } from "../completion/tool-bridge.ts";
6
+ import type { CellBridgeRuntime } from "./cell-handler.ts";
7
+ import { marshalToolResult } from "./image.ts";
8
+ import type { EvalKernel, EvalStatusEvent } from "./types.ts";
9
+
10
+ type ToolCallMessage = Extract<KernelToHostMessage, { type: "tool-call" }>;
11
+
12
+ /** The in-process (JS kernel) dispatch context for a reserved helper call; mirrors the bridge route for py/rb/jl. */
13
+ export function reservedDispatchContext(
14
+ message: ToolCallMessage,
15
+ runtime: CellBridgeRuntime,
16
+ signal: AbortSignal,
17
+ emitStatus: (event: EvalStatusEvent) => void,
18
+ ): ReservedDispatchContext {
19
+ const host = runtime.ctx.evalHandleHost;
20
+ return {
21
+ callId: message.callId,
22
+ args: message.args,
23
+ executeTool: runtime.executeTool,
24
+ taskToolName: runtime.settings.taskTools.task,
25
+ taskOutputToolName: runtime.settings.taskTools.output,
26
+ listTools: runtime.listTools,
27
+ signal,
28
+ emitStatus,
29
+ marshalToolResult,
30
+ ...(runtime.handles === undefined ? {} : { handles: runtime.handles }),
31
+ ...(host === undefined ? {} : { evalHandleHost: host }),
32
+ };
33
+ }
34
+
35
+ export function completionCallOptions(
36
+ message: ToolCallMessage,
37
+ kernel: EvalKernel,
38
+ runtime: CellBridgeRuntime,
39
+ complete: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>,
40
+ isActive: () => boolean,
41
+ ): CompletionToolCallOptions {
42
+ return {
43
+ message,
44
+ kernel,
45
+ complete,
46
+ ctx: runtime.ctx,
47
+ isActive,
48
+ ...(runtime.handles === undefined ? {} : { handles: runtime.handles }),
49
+ ...(runtime.hardDeadlineMs === undefined ? {} : { hardDeadlineMs: runtime.hardDeadlineMs }),
50
+ };
51
+ }
@@ -9,6 +9,7 @@ import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
9
9
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
10
10
  import type { ResolvedCodemodeSettings } from "../config/settings.ts";
11
11
  import type { EvalExecutionTracker } from "../extension/session-manager.ts";
12
+ import type { HandleRegistry } from "../handles/handle-registry.ts";
12
13
  import type { EvalTimeoutFactory } from "./cell-execution.ts";
13
14
  import type { EvalDetachedCellManager } from "./detached-cell-manager.ts";
14
15
  import type { EvalExecutionEventPayload } from "./eval-execution-event.ts";
@@ -50,6 +51,8 @@ export interface CreateEvalToolOptions {
50
51
  readonly imageResizer?: EvalImageResizer;
51
52
  readonly executionTracker?: EvalExecutionTracker;
52
53
  readonly cellManager?: EvalDetachedCellManager;
54
+ /** The session generation's handle registry (`wait()`, `handle()`, completion handles); absent before session_start. */
55
+ readonly handles?: HandleRegistry;
53
56
  readonly onCellSettled?: (payload: EvalExecutionEventPayload) => void;
54
57
  readonly timeoutFactory?: EvalTimeoutFactory;
55
58
  readonly proxyExecutor?: (params: EvalToolInput, signal?: AbortSignal) => Promise<AgentToolResult<EvalToolDetails>>;
@@ -224,6 +224,8 @@ async function executeCell(
224
224
  : { artifactPath: join(options.artifactsDir, `eval-${randomUUID()}.log`) }),
225
225
  ...(options.imageResizer === undefined ? {} : { imageResizer: options.imageResizer }),
226
226
  ...(kernelTools === undefined ? {} : { kernelTools }),
227
+ ...(options.handles === undefined ? {} : { handles: options.handles }),
228
+ hardDeadlineMs: cell.startedAtMs + cell.hardLimitSeconds * 1_000,
227
229
  });
228
230
  handler = activeHandler;
229
231
  cellManager.bindKernel(
package/src/tool/types.ts CHANGED
@@ -144,9 +144,24 @@ export function createEvalInputSchema(
144
144
  export type EvalKernelResult = Extract<KernelToHostMessage, { type: "result" }>;
145
145
  export type EvalToolCallMessage = Extract<KernelToHostMessage, { type: "tool-call" }>;
146
146
 
147
+ export type HostCellOutcome =
148
+ | { readonly ok: true; readonly valueRepr?: string }
149
+ | { readonly ok: false; readonly error: { readonly message: string; readonly name?: string } };
150
+
151
+ /**
152
+ * Work the host runs in the cell's place when the entry reaches the front of the kernel's queue: no code is
153
+ * sent to the interpreter, and aborting the signal (interrupt, timeout, kernel close) ends it without
154
+ * restarting the interpreter.
155
+ */
156
+ export type HostCellExecutor = (context: {
157
+ readonly signal: AbortSignal;
158
+ readonly emit: (message: KernelToHostMessage) => void;
159
+ }) => Promise<HostCellOutcome>;
160
+
147
161
  export interface EvalKernelRunInput {
148
162
  readonly cellId: string;
149
163
  readonly code: string;
164
+ readonly host?: HostCellExecutor;
150
165
  readonly timeoutMs?: number;
151
166
  readonly onStarted?: () => void;
152
167
  readonly onMessage?: (message: KernelToHostMessage) => void;