@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.
- package/CHANGELOG.md +19 -0
- package/README.md +4 -0
- package/package.json +4 -4
- 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/extension/bridge-dispatch.ts +41 -0
- package/src/extension/bridge-tool-call.ts +5 -1
- package/src/extension/host-facts.ts +18 -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 +17 -4
- 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/prelude.jl +9 -0
- package/src/kernels/jl/runner.jl +6 -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/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 +140 -4
- package/src/kernels/rb/handles.rb +97 -0
- package/src/kernels/rb/paths.rb +37 -0
- package/src/kernels/rb/prelude.rb +15 -38
- package/src/kernels/rb/runner.rb +7 -7
- 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 +2 -0
- package/src/tool/types.ts +15 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# In-cell wait()/handle() over the host's handle capability. Mirrors src/bridge/reserved.ts.
|
|
2
|
+
# `Main.wait` is never defined here: `Base.wait` gains methods for the handle types, so `wait(::Task)` for
|
|
3
|
+
# `@async` users keeps working.
|
|
4
|
+
const SENPI_RESERVED_WAIT_TOOL = "__wait__"
|
|
5
|
+
const SENPI_RESERVED_HANDLE_STATUS_TOOL = "__handle_status__"
|
|
6
|
+
const SENPI_RESERVED_HANDLE_OUTPUT_TOOL = "__handle_output__"
|
|
7
|
+
const SENPI_RESERVED_HANDLE_SEND_TOOL = "__handle_send__"
|
|
8
|
+
const SENPI_RESERVED_HANDLE_CANCEL_TOOL = "__handle_cancel__"
|
|
9
|
+
const SENPI_HANDLE_KINDS = ("agent", "completion", "workpool")
|
|
10
|
+
const SENPI_WAIT_MODES = ("all", "any", "settled")
|
|
11
|
+
const SENPI_WAIT_SOCKET_GRACE_SECONDS = 30
|
|
12
|
+
const SENPI_HANDLE_USAGE = "handle() expects an agent(...; handle=true) record, a workpool, a completion handle, or a saved {kind, id, run_epoch} reference"
|
|
13
|
+
|
|
14
|
+
struct SenpiHandleControl
|
|
15
|
+
ref::Dict{String, Any}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The legacy record's fields behave as a Dict; `control` and `ref` are properties, never keys.
|
|
19
|
+
struct SenpiHandle <: AbstractDict{String, Any}
|
|
20
|
+
fields::Dict{String, Any}
|
|
21
|
+
ref::Dict{String, Any}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
Base.iterate(handle::SenpiHandle, state...) = iterate(getfield(handle, :fields), state...)
|
|
25
|
+
Base.length(handle::SenpiHandle) = length(getfield(handle, :fields))
|
|
26
|
+
Base.get(handle::SenpiHandle, key, default) = get(getfield(handle, :fields), key, default)
|
|
27
|
+
Base.getindex(handle::SenpiHandle, key) = getfield(handle, :fields)[key]
|
|
28
|
+
Base.haskey(handle::SenpiHandle, key) = haskey(getfield(handle, :fields), key)
|
|
29
|
+
Base.keys(handle::SenpiHandle) = keys(getfield(handle, :fields))
|
|
30
|
+
Base.values(handle::SenpiHandle) = values(getfield(handle, :fields))
|
|
31
|
+
|
|
32
|
+
function Base.getproperty(handle::SenpiHandle, name::Symbol)
|
|
33
|
+
name === :ref && return copy(getfield(handle, :ref))
|
|
34
|
+
name === :control && return SenpiHandleControl(copy(getfield(handle, :ref)))
|
|
35
|
+
fields = getfield(handle, :fields)
|
|
36
|
+
key = string(name)
|
|
37
|
+
haskey(fields, key) && return fields[key]
|
|
38
|
+
getfield(handle, name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Base.propertynames(handle::SenpiHandle) = (Symbol.(collect(keys(getfield(handle, :fields))))..., :control, :ref)
|
|
42
|
+
|
|
43
|
+
function senpi_handle_ref(value)
|
|
44
|
+
value isa SenpiHandle && return copy(getfield(value, :ref))
|
|
45
|
+
value isa SenpiWorkpool && return Dict{String, Any}("kind" => "workpool", "id" => getfield(value, :pool_id), "run_epoch" => 0)
|
|
46
|
+
value isa AbstractDict || error(SENPI_HANDLE_USAGE)
|
|
47
|
+
record = Dict{String, Any}(string(key) => item for (key, item) in value)
|
|
48
|
+
pool_id = get(record, "pool_id", nothing)
|
|
49
|
+
pool_id isa AbstractString && return Dict{String, Any}("kind" => "workpool", "id" => string(pool_id), "run_epoch" => 0)
|
|
50
|
+
kind = get(record, "kind", nothing)
|
|
51
|
+
if !(kind in SENPI_HANDLE_KINDS)
|
|
52
|
+
handle_uri = get(record, "handle", nothing)
|
|
53
|
+
scheme = handle_uri isa AbstractString ? first(split(handle_uri, "://"; limit=2)) : nothing
|
|
54
|
+
kind = scheme in SENPI_HANDLE_KINDS ? scheme : nothing
|
|
55
|
+
end
|
|
56
|
+
identity = get(record, "id", get(record, "task_id", nothing))
|
|
57
|
+
(kind === nothing || !(identity isa AbstractString) || isempty(identity)) && error(SENPI_HANDLE_USAGE)
|
|
58
|
+
run_epoch = get(record, "run_epoch", kind == "workpool" ? 0 : nothing)
|
|
59
|
+
(run_epoch isa Integer && !(run_epoch isa Bool) && run_epoch >= 0) || error(SENPI_HANDLE_USAGE * "; run_epoch must be a non-negative integer")
|
|
60
|
+
Dict{String, Any}("kind" => string(kind), "id" => string(identity), "run_epoch" => Int(run_epoch))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
function senpi_handle_call(tool_name::String, arguments)
|
|
64
|
+
senpi_with_bridge_timeout_pause(() -> senpi_call_tool(tool_name, arguments))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A long-lived request bounded by the explicit timeout (plus grace for the host's reply) or else only by the
|
|
68
|
+
# cell's own end; an interrupt from the host (cancel, the cell's hard limit) closes the socket and so the
|
|
69
|
+
# host-side subscription.
|
|
70
|
+
function senpi_wait_post(arguments, timeout)
|
|
71
|
+
read_timeout = timeout === nothing ? nothing : Float64(timeout) + SENPI_WAIT_SOCKET_GRACE_SECONDS
|
|
72
|
+
payload = Dict("callId" => "jl-" * string(time_ns()), "toolName" => SENPI_RESERVED_WAIT_TOOL, "args" => arguments)
|
|
73
|
+
senpi_with_bridge_timeout_pause(() -> senpi_bridge_request("/call", payload; read_timeout=read_timeout))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
function senpi_wait(handles; timeout=nothing, mode="all")
|
|
77
|
+
items = handles === nothing ? Any[] : handles isa AbstractVector || handles isa Tuple ? collect(handles) : Any[handles]
|
|
78
|
+
if timeout !== nothing
|
|
79
|
+
(timeout isa Real && !(timeout isa Bool) && isfinite(timeout) && timeout >= 0) || error("wait() timeout must be a finite number of seconds >= 0")
|
|
80
|
+
end
|
|
81
|
+
string(mode) in SENPI_WAIT_MODES || error("wait() mode must be \"all\", \"any\" or \"settled\"")
|
|
82
|
+
arguments = Dict{String, Any}("refs" => [senpi_handle_ref(item) for item in items], "mode" => string(mode))
|
|
83
|
+
timeout === nothing || (arguments["timeout"] = timeout)
|
|
84
|
+
senpi_wait_post(arguments, timeout)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
function Base.getproperty(control::SenpiHandleControl, name::Symbol)
|
|
88
|
+
ref = getfield(control, :ref)
|
|
89
|
+
name === :ref && return copy(ref)
|
|
90
|
+
name === :status && return () -> senpi_handle_call(SENPI_RESERVED_HANDLE_STATUS_TOOL, Dict("ref" => ref))
|
|
91
|
+
if name === :output
|
|
92
|
+
return (; format="raw", offset=nothing, limit=nothing) -> begin
|
|
93
|
+
arguments = Dict{String, Any}("ref" => ref, "format" => string(format))
|
|
94
|
+
offset === nothing || (arguments["offset"] = offset)
|
|
95
|
+
limit === nothing || (arguments["limit"] = limit)
|
|
96
|
+
senpi_handle_call(SENPI_RESERVED_HANDLE_OUTPUT_TOOL, arguments)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
name === :send && return message -> senpi_handle_call(SENPI_RESERVED_HANDLE_SEND_TOOL, Dict("ref" => ref, "message" => string(message)))
|
|
100
|
+
name === :cancel && return () -> senpi_handle_call(SENPI_RESERVED_HANDLE_CANCEL_TOOL, Dict("ref" => ref))
|
|
101
|
+
name === :wait && return (; timeout=nothing) -> senpi_wait([ref]; timeout=timeout, mode="all")[1]
|
|
102
|
+
getfield(control, name)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
Base.propertynames(::SenpiHandleControl) = (:status, :output, :send, :cancel, :wait, :ref)
|
|
106
|
+
Base.show(io::IO, control::SenpiHandleControl) = Base.print(io, "<handle.control ", getfield(control, :ref)["kind"], "://", getfield(control, :ref)["id"], "@", getfield(control, :ref)["run_epoch"], ">")
|
|
107
|
+
|
|
108
|
+
function senpi_handle_view(value)
|
|
109
|
+
ref = senpi_handle_ref(value)
|
|
110
|
+
fields = Dict{String, Any}()
|
|
111
|
+
if value isa AbstractDict
|
|
112
|
+
for (key, item) in value
|
|
113
|
+
item isa Function || (fields[string(key)] = item)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
get!(fields, "id", ref["id"])
|
|
117
|
+
get!(fields, "run_epoch", ref["run_epoch"])
|
|
118
|
+
get!(fields, "handle", ref["kind"] * "://" * ref["id"])
|
|
119
|
+
SenpiHandle(fields, ref)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Block until the handles settle; never cancels work. See tool_schema("eval:wait").
|
|
123
|
+
Base.wait(handle::SenpiHandle; timeout=nothing, mode="all") = senpi_wait([handle]; timeout=timeout, mode=mode)
|
|
124
|
+
Base.wait(handles::AbstractVector{<:SenpiHandle}; timeout=nothing, mode="all") = senpi_wait(handles; timeout=timeout, mode=mode)
|
|
@@ -226,6 +226,14 @@ function workpool(agent::AbstractDict, name::AbstractString; mode=nothing)
|
|
|
226
226
|
throw(SenpiBridgeError("Host did not return a workpool identity", "workpool_unavailable"))
|
|
227
227
|
end
|
|
228
228
|
|
|
229
|
+
include("handles.jl")
|
|
230
|
+
|
|
231
|
+
# Rich view of an agent record, workpool, completion handle or saved reference; see tool_schema("eval:helpers").
|
|
232
|
+
# The barrier is `Base.wait` on the view types (never a `Main.wait`): wait(handle(node)) or wait([handle(a), handle(b)]).
|
|
233
|
+
function handle(value)
|
|
234
|
+
senpi_handle_view(value)
|
|
235
|
+
end
|
|
236
|
+
|
|
229
237
|
function completion(prompt::AbstractString; model="default", system=nothing, schema=nothing, kwargs...)
|
|
230
238
|
options = Dict{String, Any}("model" => model)
|
|
231
239
|
system !== nothing && (options["system"] = system)
|
|
@@ -234,6 +242,7 @@ function completion(prompt::AbstractString; model="default", system=nothing, sch
|
|
|
234
242
|
options[string(key)] = value
|
|
235
243
|
end
|
|
236
244
|
response = senpi_with_bridge_timeout_pause(() -> senpi_completion(string(prompt), options))
|
|
245
|
+
get(options, "handle", false) === true && return handle(response)
|
|
237
246
|
response isa AbstractDict || return response
|
|
238
247
|
haskey(response, "value") && return response["value"]
|
|
239
248
|
get(response, "text", response)
|
package/src/kernels/jl/runner.jl
CHANGED
|
@@ -224,12 +224,15 @@ function senpi_http_body(response::AbstractString)
|
|
|
224
224
|
String(output)
|
|
225
225
|
end
|
|
226
226
|
|
|
227
|
-
|
|
227
|
+
# `read_timeout` (seconds) bounds only the long-lived `wait()` request; ordinary calls read until the host closes.
|
|
228
|
+
function senpi_bridge_request(path::String, payload; read_timeout=nothing)
|
|
228
229
|
port = get(senpi_connection, "port", nothing)
|
|
229
230
|
token = get(senpi_connection, "token", nothing)
|
|
230
231
|
port isa Integer && token isa AbstractString || error("Julia tool bridge is not initialized")
|
|
231
232
|
body = senpi_json(payload)
|
|
232
233
|
socket = connect(ip"127.0.0.1", port)
|
|
234
|
+
timed_out = Ref(false)
|
|
235
|
+
timer = read_timeout === nothing ? nothing : Timer(_ -> (timed_out[] = true; close(socket)), Float64(read_timeout))
|
|
233
236
|
try
|
|
234
237
|
request = join([
|
|
235
238
|
"POST " * path * " HTTP/1.1",
|
|
@@ -244,12 +247,14 @@ function senpi_bridge_request(path::String, payload)
|
|
|
244
247
|
Base.write(socket, request)
|
|
245
248
|
flush(socket)
|
|
246
249
|
response = Base.read(socket, String)
|
|
250
|
+
timed_out[] && throw(SenpiBridgeError("bridge request timed out after $(read_timeout)s", "bridge_timeout"))
|
|
247
251
|
parsed = senpi_json_parse(senpi_http_body(response))
|
|
248
252
|
parsed isa AbstractDict || error("Bridge returned invalid JSON")
|
|
249
253
|
get(parsed, "ok", false) === true && return get(parsed, "value", nothing)
|
|
250
254
|
failure = get(parsed, "error", parsed)
|
|
251
255
|
throw(SenpiBridgeError(failure isa AbstractDict ? string(get(failure, "message", failure)) : string(failure), failure isa AbstractDict ? get(failure, "code", nothing) : nothing))
|
|
252
256
|
finally
|
|
257
|
+
timer === nothing || close(timer)
|
|
253
258
|
close(socket)
|
|
254
259
|
end
|
|
255
260
|
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// In-cell `wait()` / `handle()` over the host's handle capability. Mirrors src/bridge/reserved.ts; this
|
|
2
|
+
// worker asset cannot import TypeScript. Every call rides the ordinary bridge-call path (`callTool`), so
|
|
3
|
+
// the run budget pauses while the cell is parked and the hard limit still applies.
|
|
4
|
+
const RESERVED_WAIT_TOOL = "__wait__";
|
|
5
|
+
const RESERVED_HANDLE_STATUS_TOOL = "__handle_status__";
|
|
6
|
+
const RESERVED_HANDLE_OUTPUT_TOOL = "__handle_output__";
|
|
7
|
+
const RESERVED_HANDLE_SEND_TOOL = "__handle_send__";
|
|
8
|
+
const RESERVED_HANDLE_CANCEL_TOOL = "__handle_cancel__";
|
|
9
|
+
const KINDS = new Set(["agent", "completion", "workpool"]);
|
|
10
|
+
const WAIT_MODES = new Set(["all", "any", "settled"]);
|
|
11
|
+
const USAGE = "handle() expects an agent(..., {handle: true}) record, a workpool, a completion handle, or a saved {kind, id, run_epoch} reference";
|
|
12
|
+
|
|
13
|
+
export function createHandleHelpers(callTool) {
|
|
14
|
+
const wait = async (handles, options) => {
|
|
15
|
+
const list = Array.isArray(handles) ? handles : handles === undefined || handles === null ? [] : [handles];
|
|
16
|
+
const refs = list.map(toHandleRef);
|
|
17
|
+
const { timeout, mode } = waitOptions(options);
|
|
18
|
+
return await callTool(RESERVED_WAIT_TOOL, {
|
|
19
|
+
refs,
|
|
20
|
+
...(timeout === undefined ? {} : { timeout }),
|
|
21
|
+
...(mode === undefined ? {} : { mode }),
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
const handle = value => {
|
|
25
|
+
const ref = toHandleRef(value);
|
|
26
|
+
const view = Object.create(Object.prototype);
|
|
27
|
+
if (isPlainObject(value)) {
|
|
28
|
+
for (const [key, field] of Object.entries(value)) {
|
|
29
|
+
if (typeof field !== "function") view[key] = field;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!Object.hasOwn(view, "id")) view.id = ref.id;
|
|
33
|
+
if (!Object.hasOwn(view, "run_epoch")) view.run_epoch = ref.run_epoch;
|
|
34
|
+
if (!Object.hasOwn(view, "handle")) view.handle = `${ref.kind}://${ref.id}`;
|
|
35
|
+
const control = Object.freeze({
|
|
36
|
+
status: async () => await callTool(RESERVED_HANDLE_STATUS_TOOL, { ref }),
|
|
37
|
+
output: async (opts = {}) => await callTool(RESERVED_HANDLE_OUTPUT_TOOL, { ref, ...outputOptions(opts) }),
|
|
38
|
+
send: async message => await callTool(RESERVED_HANDLE_SEND_TOOL, { ref, message: String(message) }),
|
|
39
|
+
cancel: async () => await callTool(RESERVED_HANDLE_CANCEL_TOOL, { ref }),
|
|
40
|
+
wait: async (opts = {}) => (await wait([ref], { ...waitOptions(opts), mode: "all" }))[0],
|
|
41
|
+
});
|
|
42
|
+
Object.defineProperty(view, "ref", { value: Object.freeze({ ...ref }), enumerable: false });
|
|
43
|
+
Object.defineProperty(view, "control", { value: control, enumerable: false });
|
|
44
|
+
return view;
|
|
45
|
+
};
|
|
46
|
+
return { wait, handle };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Accepts legacy agent records, handle() views, completion handles, workpools and saved refs. */
|
|
50
|
+
export function toHandleRef(value) {
|
|
51
|
+
if (!isPlainObject(value)) throw new TypeError(USAGE);
|
|
52
|
+
if (isPlainObject(value.ref) && KINDS.has(value.ref.kind)) return toHandleRef(value.ref);
|
|
53
|
+
if (typeof value.pool_id === "string") return { kind: "workpool", id: value.pool_id, run_epoch: 0 };
|
|
54
|
+
const kind = KINDS.has(value.kind) ? value.kind : kindFromHandle(value.handle);
|
|
55
|
+
const id = value.id ?? value.task_id;
|
|
56
|
+
if (kind === undefined || typeof id !== "string" || id.length === 0) throw new TypeError(USAGE);
|
|
57
|
+
const run_epoch = value.run_epoch ?? (kind === "workpool" ? 0 : undefined);
|
|
58
|
+
if (!Number.isInteger(run_epoch) || run_epoch < 0) throw new TypeError(`${USAGE}; run_epoch must be a non-negative integer`);
|
|
59
|
+
return { kind, id, run_epoch };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function kindFromHandle(handle) {
|
|
63
|
+
if (typeof handle !== "string") return undefined;
|
|
64
|
+
const scheme = handle.split("://")[0];
|
|
65
|
+
return KINDS.has(scheme) ? scheme : undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function waitOptions(options) {
|
|
69
|
+
if (options === undefined || options === null) return {};
|
|
70
|
+
if (!isPlainObject(options)) throw new TypeError("wait() accepts { timeout, mode }");
|
|
71
|
+
for (const key of Object.keys(options)) {
|
|
72
|
+
if (key !== "timeout" && key !== "mode") throw new TypeError("wait() accepts { timeout, mode }");
|
|
73
|
+
}
|
|
74
|
+
const { timeout, mode } = options;
|
|
75
|
+
if (timeout !== undefined && timeout !== null && (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout < 0)) {
|
|
76
|
+
throw new TypeError("wait() timeout must be a finite number of seconds >= 0");
|
|
77
|
+
}
|
|
78
|
+
if (mode !== undefined && mode !== null && !WAIT_MODES.has(mode)) throw new TypeError('wait() mode must be "all", "any" or "settled"');
|
|
79
|
+
return { ...(timeout === undefined || timeout === null ? {} : { timeout }), ...(mode === undefined || mode === null ? {} : { mode }) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function outputOptions(options) {
|
|
83
|
+
if (!isPlainObject(options)) throw new TypeError("control.output() accepts { format, offset, limit }");
|
|
84
|
+
const picked = {};
|
|
85
|
+
for (const key of ["format", "offset", "limit"]) {
|
|
86
|
+
if (options[key] !== undefined && options[key] !== null) picked[key] = options[key];
|
|
87
|
+
}
|
|
88
|
+
return picked;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isPlainObject(value) {
|
|
92
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
93
|
+
}
|
|
@@ -2,9 +2,15 @@ export type KernelToolFunction = {
|
|
|
2
2
|
readonly name: string;
|
|
3
3
|
};
|
|
4
4
|
|
|
5
|
+
export type KernelToolNamespaceRegistry = {
|
|
6
|
+
readonly defined?: () => string[];
|
|
7
|
+
readonly undefine?: (name: unknown) => boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
5
10
|
export function createToolNamespace(
|
|
6
11
|
define: (fn: KernelToolFunction, metadata?: unknown) => unknown,
|
|
7
12
|
callHost: (name: string, args: unknown) => Promise<unknown>,
|
|
13
|
+
registry?: KernelToolNamespaceRegistry,
|
|
8
14
|
): ((fn: KernelToolFunction, metadata?: unknown) => unknown) & {
|
|
9
15
|
readonly [name: string]: (args?: unknown) => Promise<unknown>;
|
|
10
16
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export function createToolNamespace(define, callHost) {
|
|
1
|
+
export function createToolNamespace(define, callHost, registry = {}) {
|
|
2
2
|
const registrar = function tool(fn, metadata) {
|
|
3
3
|
return define(fn, metadata);
|
|
4
4
|
};
|
|
@@ -6,6 +6,8 @@ export function createToolNamespace(define, callHost) {
|
|
|
6
6
|
get(target, prop) {
|
|
7
7
|
if (typeof prop !== "string") return undefined;
|
|
8
8
|
if (prop in Function.prototype || prop === "arguments" || prop === "caller") return target[prop];
|
|
9
|
+
if (prop === "defined" && registry.defined) return () => registry.defined();
|
|
10
|
+
if (prop === "undefine" && registry.undefine) return (name) => registry.undefine(name);
|
|
9
11
|
return async (args) => await callHost(prop, args ?? {});
|
|
10
12
|
},
|
|
11
13
|
});
|
|
@@ -22,6 +22,8 @@ export function createKernelToolRegistry(options?: KernelToolRegistryOptions): {
|
|
|
22
22
|
},
|
|
23
23
|
signal?: AbortSignal,
|
|
24
24
|
): Promise<unknown>;
|
|
25
|
+
defined(): string[];
|
|
26
|
+
undefine(name: unknown): boolean;
|
|
25
27
|
bumpGeneration(): number;
|
|
26
28
|
setCollisionNames(hostToolNames?: readonly string[], foreignLanguageNames?: readonly string[]): void;
|
|
27
29
|
};
|
|
@@ -6,7 +6,8 @@ import { orderedArgs, resolveToolMetadata, validateInvokeArgs } from "./kernel-t
|
|
|
6
6
|
|
|
7
7
|
export { createToolNamespace };
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// "defined" and "undefine" are the tool namespace's own members, so a kernel tool by those names could never be reached.
|
|
10
|
+
const DEFAULT_RESERVED = Object.freeze(["__agent__", "__output__", "__schema__", "defined", "undefine"]);
|
|
10
11
|
|
|
11
12
|
function currentNames(source) {
|
|
12
13
|
return typeof source === "function" ? source() : (source ?? []);
|
|
@@ -47,21 +48,22 @@ export function createKernelToolRegistry(options = {}) {
|
|
|
47
48
|
assertJs();
|
|
48
49
|
const parsed = parseToolFunction(fn);
|
|
49
50
|
const resolved = resolveToolMetadata(metadata, parsed.params);
|
|
50
|
-
|
|
51
|
+
const name = resolved.name ?? parsed.name;
|
|
52
|
+
if (sanitizeNamePart(name) !== name || name.length > MCP_TOOL_NAME_MAX_LENGTH) {
|
|
51
53
|
throw kernelToolError("invalid_tool_definition", "Kernel tool name must match MCP name grammar");
|
|
52
54
|
}
|
|
53
|
-
const normalizedName =
|
|
54
|
-
const key = kernelToolKey(
|
|
55
|
-
if (reservedKeys.has(key)) throw kernelToolError("reserved_tool_name", `Kernel tool name is reserved: ${
|
|
55
|
+
const normalizedName = name;
|
|
56
|
+
const key = kernelToolKey(name);
|
|
57
|
+
if (reservedKeys.has(key)) throw kernelToolError("reserved_tool_name", `Kernel tool name is reserved: ${name}`);
|
|
56
58
|
if (hasNameKey(hostSource, key) || hasNameKey(foreignSource, key)) {
|
|
57
|
-
throw kernelToolError("tool_name_collision", `Kernel tool name collides: ${
|
|
59
|
+
throw kernelToolError("tool_name_collision", `Kernel tool name collides: ${name}`);
|
|
58
60
|
}
|
|
59
61
|
const existing = entries.get(key);
|
|
60
|
-
if (existing && existing.originalName !==
|
|
61
|
-
throw kernelToolError("tool_name_collision", `Kernel tool name collides: ${
|
|
62
|
+
if (existing && existing.originalName !== name) {
|
|
63
|
+
throw kernelToolError("tool_name_collision", `Kernel tool name collides: ${name}`);
|
|
62
64
|
}
|
|
63
65
|
const entry = {
|
|
64
|
-
originalName:
|
|
66
|
+
originalName: name,
|
|
65
67
|
normalizedName,
|
|
66
68
|
fn,
|
|
67
69
|
params: parsed.params,
|
|
@@ -114,6 +116,13 @@ export function createKernelToolRegistry(options = {}) {
|
|
|
114
116
|
throw kernelToolError("kernel_tool_failed", error instanceof Error ? error.message : String(error));
|
|
115
117
|
}
|
|
116
118
|
},
|
|
119
|
+
defined() {
|
|
120
|
+
return [...entries.values()].map((entry) => entry.normalizedName).sort();
|
|
121
|
+
},
|
|
122
|
+
undefine(name) {
|
|
123
|
+
if (typeof name !== "string") return false;
|
|
124
|
+
return entries.delete(kernelToolKey(name));
|
|
125
|
+
},
|
|
117
126
|
bumpGeneration() {
|
|
118
127
|
generation += 1;
|
|
119
128
|
entries.clear();
|
|
@@ -18,7 +18,11 @@ export function resolveToolMetadata(metadata, params) {
|
|
|
18
18
|
if (typeof description !== "string") {
|
|
19
19
|
throw kernelToolError("invalid_tool_definition", "tool() description must be a string");
|
|
20
20
|
}
|
|
21
|
-
if (metadata.
|
|
21
|
+
if (metadata.name !== undefined && typeof metadata.name !== "string") {
|
|
22
|
+
throw kernelToolError("invalid_tool_definition", "tool() name must be a string");
|
|
23
|
+
}
|
|
24
|
+
const named = metadata.name === undefined ? {} : { name: metadata.name };
|
|
25
|
+
if (metadata.schema === undefined) return { ...named, description, input_schema: defaultInputSchema(params) };
|
|
22
26
|
const schema = metadata.schema;
|
|
23
27
|
if (!isPlainJsonObject(schema) || schema.type !== "object" || !isPlainJsonObject(schema.properties)) {
|
|
24
28
|
throw kernelToolError("invalid_tool_definition", "tool() metadata schema must be a JSON object schema");
|
|
@@ -27,7 +31,7 @@ export function resolveToolMetadata(metadata, params) {
|
|
|
27
31
|
if (propertyKeys.length !== params.length || params.some((name) => !Object.hasOwn(schema.properties, name))) {
|
|
28
32
|
throw kernelToolError("invalid_tool_definition", "tool() metadata schema properties must match parameters");
|
|
29
33
|
}
|
|
30
|
-
return { description, input_schema: schema };
|
|
34
|
+
return { ...named, description, input_schema: schema };
|
|
31
35
|
}
|
|
32
36
|
|
|
33
37
|
export function validateInvokeArgs(schema, args) {
|
|
@@ -8,6 +8,7 @@ import { awaitMaybePromise, indirectEval, wrapUserCode } from "./worker-indirect
|
|
|
8
8
|
import { installShellCapture } from "./worker-shell-capture.js";
|
|
9
9
|
import { bindKernelBun } from "./worker-webview.js";
|
|
10
10
|
import { createWorkpool } from "./workpool.js";
|
|
11
|
+
import { createHandleHelpers } from "./handles.js";
|
|
11
12
|
import { inKernelToolInvoke } from "./kernel-tools-context.js";
|
|
12
13
|
import { kernelToolError } from "./kernel-tools-errors.js";
|
|
13
14
|
import { createKernelToolRegistry, createToolNamespace } from "./kernel-tools-registry.js";
|
|
@@ -137,10 +138,18 @@ export class JsWorkerRuntime {
|
|
|
137
138
|
globalThis.workpool = (agent, name, options) => createWorkpool((toolName, args) => this.#callTool(toolName, args), agent, name, options);
|
|
138
139
|
globalThis.parallel = async thunks => await this.#parallel(thunks);
|
|
139
140
|
globalThis.pipeline = async (items, ...stages) => await this.#pipeline(items, stages);
|
|
140
|
-
|
|
141
|
+
const handles = createHandleHelpers(async (toolName, args) => await this.#callTool(toolName, args));
|
|
142
|
+
globalThis.completion = async (prompt, opts) => {
|
|
143
|
+
const value = await this.#callTool("completion", { prompt, opts });
|
|
144
|
+
// The host answers a {handle: true} request with a saved reference; the cell gets the control view.
|
|
145
|
+
return isPlainObject(opts) && opts.handle === true ? handles.handle(value) : value;
|
|
146
|
+
};
|
|
147
|
+
globalThis.wait = async (list, options) => await handles.wait(list, options);
|
|
148
|
+
globalThis.handle = value => handles.handle(value);
|
|
141
149
|
globalThis.tool = createToolNamespace(
|
|
142
150
|
(fn, metadata) => this.#tools.define(fn, metadata),
|
|
143
151
|
async (name, args) => await this.#callTool(name, args),
|
|
152
|
+
{ defined: () => this.#tools.defined(), undefine: (name) => this.#tools.undefine(name) },
|
|
144
153
|
);
|
|
145
154
|
globalThis.tools = globalThis.tool;
|
|
146
155
|
const originalLog = console.log.bind(console);
|
|
@@ -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;
|