@code-yeongyu/senpi-codemode 2026.7.25-2 → 2026.7.28
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 +39 -0
- package/README.md +45 -19
- package/package.json +3 -3
- package/src/bridge/http-server.ts +6 -1
- package/src/bridge/reserved.ts +2 -0
- package/src/bridges/output-bridge.ts +0 -1
- package/src/bridges/reserved-dispatch.ts +56 -0
- package/src/bridges/schema-bridge.ts +69 -0
- package/src/bridges/schema-hint.ts +140 -0
- package/src/extension/eval-notifier.ts +40 -0
- package/src/extension/runtime-factory.ts +3 -1
- package/src/index.ts +50 -66
- package/src/kernels/jl/prelude.jl +6 -0
- package/src/kernels/js/context-manager.ts +5 -2
- package/src/kernels/js/local-module-loader.ts +4 -0
- package/src/kernels/js/worker-runtime.js +6 -0
- package/src/kernels/py/kernel-contract.ts +2 -0
- package/src/kernels/py/kernel.ts +19 -4
- package/src/kernels/py/prelude.py +17 -0
- package/src/kernels/rb/prelude.rb +6 -0
- package/src/kernels/shared/subprocess-kernel.ts +8 -5
- package/src/prompt/eval-prompt.ts +28 -5
- package/src/tool/cell-handler.ts +20 -18
- package/src/tool/detached-cell-manager.ts +322 -0
- package/src/tool/eval-tool.ts +256 -18
- package/src/tool/interrupt-note.ts +58 -0
- package/src/tool/render.ts +28 -6
- package/src/tool/status-events.ts +20 -0
- package/src/tool/types.ts +66 -23
- package/src/codemode/runtime.ts +0 -258
- package/src/codemode/tools.ts +0 -106
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,45 @@
|
|
|
12
12
|
|
|
13
13
|
### Removed
|
|
14
14
|
|
|
15
|
+
## [2026.7.28] - 2026-07-28
|
|
16
|
+
|
|
17
|
+
### Breaking Changes
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Add `tool_schema()` and return parameter schemas from failed eval tool calls so cells can inspect and self-correct tool invocations ([#407](https://github.com/code-yeongyu/senpi/pull/407)).
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- Allow eval cells and extensions to activate named searchable tools lazily on the calling surface without globally widening the active tool set ([#408](https://github.com/code-yeongyu/senpi/pull/408)).
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
### Removed
|
|
30
|
+
|
|
31
|
+
## [2026.7.26] - 2026-07-26
|
|
32
|
+
|
|
33
|
+
### Breaking Changes
|
|
34
|
+
|
|
35
|
+
- Remove the separate GPT-only `exec`/`wait` runtime; GPT models now compose active tools through the persistent `eval` surface.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- Detach interactive `eval` cells on timeout, inject completion notifications, and support `peek`/`stop` actions without blocking other language kernels.
|
|
40
|
+
- Report whether Python kernel state survived an interrupt or timeout, with a real-surface QA driver covering the contract.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- Bound each cell's retained status history and summarize omitted events ([#334](https://github.com/code-yeongyu/senpi/pull/334) by [@minpeter](https://github.com/minpeter)).
|
|
45
|
+
- Make task-output lookups non-blocking and document detached-cell state, output, and artifact behavior.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- Preserve Python state when interruption succeeds, report truthful state when it does not, and tolerate kernels predating the interrupt-outcome contract.
|
|
50
|
+
- Stop normal bridge-request completion from aborting still-running host tool calls.
|
|
51
|
+
|
|
52
|
+
### Removed
|
|
53
|
+
|
|
15
54
|
## [2026.7.25-2] - 2026-07-25
|
|
16
55
|
|
|
17
56
|
### Breaking Changes
|
package/README.md
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
# @code-yeongyu/senpi-codemode
|
|
2
2
|
|
|
3
3
|
`@code-yeongyu/senpi-codemode` is Senpi's source-only Code Mode extension. It
|
|
4
|
-
registers persistent-kernel `eval` for every eligible
|
|
5
|
-
`
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
registers the persistent-kernel `eval` execution surface for every eligible
|
|
5
|
+
model. `eval` owns one persistent kernel per enabled language and re-registers
|
|
6
|
+
at session start after configuration, interpreter availability, and active
|
|
7
|
+
task-tool names are known.
|
|
8
8
|
|
|
9
9
|
## Capabilities
|
|
10
10
|
|
|
11
11
|
- Persistent JavaScript, Python, Ruby, and Julia cells. State survives later
|
|
12
12
|
cells in the same language until reset, restart, or session disposal.
|
|
13
|
+
- Timeout detachment for interactive `eval`: long pure-compute cells return a
|
|
14
|
+
handle and continue in their existing kernel. Completion is injected with the
|
|
15
|
+
final value/error and buffered output; use `eval({ action: "peek"|"stop",
|
|
16
|
+
cell_id })` to inspect or terminate a detached cell.
|
|
13
17
|
- Loopback, bearer-authenticated kernel bridge with bounded JSONL frames.
|
|
14
18
|
- Structured status events for file operations, environment access, phases,
|
|
15
19
|
bridge activity, and delegated task progress.
|
|
@@ -20,8 +24,8 @@ configuration, interpreter availability, and active task-tool names are known.
|
|
|
20
24
|
fallbacks.
|
|
21
25
|
- JavaScript import rewriting for supported local modules and package imports
|
|
22
26
|
in the persistent Node.js worker.
|
|
23
|
-
- GPT
|
|
24
|
-
active tools through `
|
|
27
|
+
- GPT models receive a terse `eval` prompt dialect that prioritizes composing
|
|
28
|
+
active tools through `tool.<name>(args)` and documents detach-on-timeout.
|
|
25
29
|
|
|
26
30
|
## Kernels
|
|
27
31
|
|
|
@@ -68,13 +72,13 @@ Configuration is loaded in this order:
|
|
|
68
72
|
| Key | Default | Effect |
|
|
69
73
|
| --- | --- | --- |
|
|
70
74
|
| `languages` | `py`/`js` enabled; `rb`/`jl` disabled | Selects desired languages before interpreter detection. |
|
|
71
|
-
| `cellTimeoutSeconds` | `30` | Idle timeout for one cell unless the call supplies `timeout
|
|
75
|
+
| `cellTimeoutSeconds` | `30` | Idle timeout for one cell unless the call supplies `timeout`; interactive calls detach by default and print/json calls error. |
|
|
72
76
|
| `parallelPoolWidth` | `4` | Maximum concurrent `parallel()` thunks. |
|
|
73
77
|
| `taskTools.task` | `"task"` | Registered tool name used by `agent()`. |
|
|
74
78
|
| `taskTools.output` | `"task_output"` | Registered tool name used by `output()`. |
|
|
75
79
|
| `outputSink.headBytes` | `20480` | Bytes retained from the beginning of a middle-truncated preview; `0` disables it. |
|
|
76
80
|
| `outputSink.maxColumns` | `768` | Maximum rendered output columns; `0` disables column clamping. |
|
|
77
|
-
| `statusEvents` | `true` | Enables kernel status-event forwarding and rendering. |
|
|
81
|
+
| `statusEvents` | `true` | Enables kernel status-event forwarding and rendering. Each cell retains at most 100 status rows; after overflow, one omitted-count row precedes the latest 99 events. |
|
|
78
82
|
|
|
79
83
|
`SENPI_CODEMODE_PY`, `SENPI_CODEMODE_JS`, `SENPI_CODEMODE_RB`, and
|
|
80
84
|
`SENPI_CODEMODE_JL` override the corresponding file setting. `1` or `true`
|
|
@@ -96,7 +100,8 @@ options object and asynchronous helpers are `await`-able.
|
|
|
96
100
|
| `read(path, offset?, limit?)` | Reads text with 1-indexed line slicing. `local://` paths resolve under the session artifact root. |
|
|
97
101
|
| `write(path, content)` | Creates parent directories and writes text. `local://` paths persist in the session artifact root. |
|
|
98
102
|
| `env(key?, value?)` | Reads all kernel environment values, one value, or sets one value. |
|
|
99
|
-
| `tool.<name>(args)`
|
|
103
|
+
| `tool.<name>(args)` | Invokes an active Senpi tool through the normal `pi.executeTool` pipeline. |
|
|
104
|
+
| `tool_schema(name?)` | Returns a tool's parameter schema without calling it; omit `name` to list tool names. |
|
|
100
105
|
| `completion(prompt, model?, system?, schema?)` | Requests a one-shot host completion; `schema` asks the host to parse structured output. |
|
|
101
106
|
| `agent(prompt, ...)` | Delegates to the configured active `taskTools.task` tool. Supports background handles and structured JSON results. |
|
|
102
107
|
| `output(ids, format?, offset?, limit?)` | Delegates transcript retrieval to the configured active `taskTools.output` tool. |
|
|
@@ -104,14 +109,37 @@ options object and asynchronous helpers are `await`-able.
|
|
|
104
109
|
| `pipeline(items, ...stages)` | Applies stages left to right with a barrier between stages. |
|
|
105
110
|
| `log(message)` / `phase(title)` | Emits progress text and starts a status phase. |
|
|
106
111
|
|
|
112
|
+
When a `tool.<name>()` call fails argument validation, the error delivered back
|
|
113
|
+
into the cell carries the tool's expected parameters, so the cell can correct the
|
|
114
|
+
arguments and retry instead of falling back to one-at-a-time tool calls.
|
|
115
|
+
`tool_schema()` exposes the same catalog up front.
|
|
116
|
+
|
|
107
117
|
`agent()` is available only when the configured task tool is active in the
|
|
108
|
-
session. `output()` similarly requires the configured task-output tool
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
118
|
+
session. `output()` similarly requires the configured task-output tool and
|
|
119
|
+
returns immediately: a running task reports its current status, while completed
|
|
120
|
+
tasks return the requested transcript. Missing tools produce a clear
|
|
121
|
+
availability error instead of importing an orchestration package. `agent()`
|
|
122
|
+
delegates through the tool contract, so task-engine permissions, progress
|
|
123
|
+
updates, and transcripts remain owned by that engine.
|
|
112
124
|
`isolated`, `apply`, and `merge` are accepted for compatibility but emit a
|
|
113
125
|
warning because this task-engine integration has no isolation model.
|
|
114
126
|
|
|
127
|
+
## Detached cells
|
|
128
|
+
|
|
129
|
+
`eval` accepts `on_timeout: "detach"|"error"`. The default is `"detach"` in
|
|
130
|
+
interactive TUI, RPC, and app-server sessions; print and JSON one-shot runs
|
|
131
|
+
default to `"error"` so their result is never silently detached. A detached
|
|
132
|
+
cell keeps only its own language kernel busy. A new same-language call returns
|
|
133
|
+
a busy error with its cell id and output tail; calls in other languages continue
|
|
134
|
+
normally. Do not re-run the cell.
|
|
135
|
+
|
|
136
|
+
Use `eval({ action: "peek", cell_id })` for its state and buffered output, or
|
|
137
|
+
`eval({ action: "stop", cell_id })` to cancel it. Python stop interrupts the
|
|
138
|
+
existing kernel and preserves variables. JavaScript stop kills and restarts its
|
|
139
|
+
worker, so JavaScript VM state is lost. Detached completion messages state when
|
|
140
|
+
kernel variables are available to the next eval cell; oversized buffered output
|
|
141
|
+
is written under the session local root and referenced as `local://…`.
|
|
142
|
+
|
|
115
143
|
## Output and artifacts
|
|
116
144
|
|
|
117
145
|
Cell output is streamed while the cell runs. Large streams spill to an absolute
|
|
@@ -139,11 +167,9 @@ Session generations fence retired kernels and callbacks; each cell settles once
|
|
|
139
167
|
across completion, errors, cancellation, timeout, bridge failure, or a kernel
|
|
140
168
|
crash.
|
|
141
169
|
|
|
142
|
-
GPT
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
`eval` kernels. `eval`, `exec`, and `wait` are excluded from the nested tool
|
|
146
|
-
namespace to prevent recursive Code Mode execution.
|
|
170
|
+
GPT models use the same JavaScript `eval` worker trust boundary as other JavaScript cells;
|
|
171
|
+
there is no separate execution runtime. `eval` is excluded from the nested tool
|
|
172
|
+
namespace to prevent recursive execution.
|
|
147
173
|
|
|
148
174
|
## Validation
|
|
149
175
|
|
|
@@ -157,5 +183,5 @@ npm run check
|
|
|
157
183
|
|
|
158
184
|
Direct real-surface QA drivers live in `scripts/qa-*.ts`: kernel cells
|
|
159
185
|
(`qa-py-cell.ts`, `qa-js-cell.ts`, `qa-rb-cell.ts`, `qa-jl-cell.ts`), end-to-end
|
|
160
|
-
extension execution (`qa-e2e-eval.ts
|
|
186
|
+
extension execution (`qa-e2e-eval.ts`), and renderer output
|
|
161
187
|
(`qa-render-dump.ts`).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-codemode",
|
|
3
|
-
"version": "2026.7.
|
|
3
|
+
"version": "2026.7.28",
|
|
4
4
|
"description": "Source-only senpi extension package for codemode evaluation tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -30,14 +30,14 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@babel/parser": "7.29.7",
|
|
33
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@^2026.7.
|
|
33
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@^2026.7.28",
|
|
34
34
|
"typebox": "1.1.38"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
37
|
"@code-yeongyu/senpi": "*"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@code-yeongyu/senpi": "2026.7.
|
|
40
|
+
"@code-yeongyu/senpi": "2026.7.28"
|
|
41
41
|
},
|
|
42
42
|
"keywords": [
|
|
43
43
|
"senpi",
|
|
@@ -75,7 +75,12 @@ async function handleRequest(
|
|
|
75
75
|
options: BridgeServerOptions,
|
|
76
76
|
): Promise<void> {
|
|
77
77
|
const abortController = new AbortController();
|
|
78
|
-
|
|
78
|
+
// IncomingMessage "close" fires on normal message completion in Node >= 16,
|
|
79
|
+
// so premature disconnect must be detected on the response side instead:
|
|
80
|
+
// its "close" without a finished response means the connection died mid-call.
|
|
81
|
+
response.on("close", () => {
|
|
82
|
+
if (!response.writableFinished) abortController.abort();
|
|
83
|
+
});
|
|
79
84
|
if (request.method !== "POST") {
|
|
80
85
|
sendJson(response, 404, { ok: false, error: transportError("not_found", "Bridge route was not found") });
|
|
81
86
|
return;
|
package/src/bridge/reserved.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
export const RESERVED_AGENT_TOOL = "__agent__" as const;
|
|
4
4
|
/** ADAPTATION: senpi delegates output through a reserved kernel-side tool name. */
|
|
5
5
|
export const RESERVED_OUTPUT_TOOL = "__output__" as const;
|
|
6
|
+
/** ADAPTATION: senpi resolves tool parameter schemas through a reserved kernel-side tool name. */
|
|
7
|
+
export const RESERVED_SCHEMA_TOOL = "__schema__" as const;
|
|
6
8
|
/** Canonical oh-my-pi eval-timeout pause operation. */
|
|
7
9
|
export const TIMEOUT_PAUSE_OP = "timeout-pause" as const;
|
|
8
10
|
/** Canonical oh-my-pi eval-timeout resume operation. */
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@code-yeongyu/senpi";
|
|
2
|
+
import { RESERVED_AGENT_TOOL, RESERVED_OUTPUT_TOOL, RESERVED_SCHEMA_TOOL } from "../bridge/reserved.ts";
|
|
3
|
+
import type { EvalStatusEvent, ExecuteTool } from "../tool/types.ts";
|
|
4
|
+
import { type AgentExecuteTool, runEvalAgent } from "./agent-bridge.ts";
|
|
5
|
+
import { type MarshalledToolResult, type OutputExecuteTool, runEvalOutput } from "./output-bridge.ts";
|
|
6
|
+
import { type EvalSchemaToolInfo, runEvalSchema } from "./schema-bridge.ts";
|
|
7
|
+
|
|
8
|
+
export interface ReservedDispatchContext {
|
|
9
|
+
readonly callId: string;
|
|
10
|
+
readonly args: unknown;
|
|
11
|
+
readonly executeTool: AgentExecuteTool & OutputExecuteTool & ExecuteTool;
|
|
12
|
+
readonly taskToolName: string;
|
|
13
|
+
readonly taskOutputToolName: string;
|
|
14
|
+
readonly listTools: (() => readonly EvalSchemaToolInfo[]) | undefined;
|
|
15
|
+
readonly signal: AbortSignal | undefined;
|
|
16
|
+
readonly emitStatus: (event: EvalStatusEvent) => void;
|
|
17
|
+
readonly marshalToolResult: (result: AgentToolResult<unknown>) => MarshalledToolResult;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
class SchemaUnavailableError extends Error {
|
|
21
|
+
readonly name = "SchemaUnavailableError";
|
|
22
|
+
|
|
23
|
+
constructor() {
|
|
24
|
+
super("tool_schema() unavailable: this session does not expose a tool catalog");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isReservedToolName(toolName: string): boolean {
|
|
29
|
+
return toolName === RESERVED_AGENT_TOOL || toolName === RESERVED_OUTPUT_TOOL || toolName === RESERVED_SCHEMA_TOOL;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function runReservedTool(toolName: string, context: ReservedDispatchContext): Promise<unknown> {
|
|
33
|
+
if (toolName === RESERVED_AGENT_TOOL) {
|
|
34
|
+
return await runEvalAgent(context.args, {
|
|
35
|
+
callId: context.callId,
|
|
36
|
+
taskToolName: context.taskToolName,
|
|
37
|
+
executeTool: context.executeTool,
|
|
38
|
+
...(context.signal === undefined ? {} : { signal: context.signal }),
|
|
39
|
+
emitStatus: context.emitStatus,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
if (toolName === RESERVED_OUTPUT_TOOL) {
|
|
43
|
+
return await runEvalOutput(context.args, {
|
|
44
|
+
taskOutputToolName: context.taskOutputToolName,
|
|
45
|
+
executeTool: context.executeTool,
|
|
46
|
+
...(context.signal === undefined ? {} : { signal: context.signal }),
|
|
47
|
+
marshalToolResult: context.marshalToolResult,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (toolName === RESERVED_SCHEMA_TOOL) {
|
|
51
|
+
const listTools = context.listTools;
|
|
52
|
+
if (listTools === undefined) throw new SchemaUnavailableError();
|
|
53
|
+
return runEvalSchema(context.args, { listTools });
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`runReservedTool received a non-reserved tool name: ${toolName}`);
|
|
56
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type Static, Type } from "typebox";
|
|
2
|
+
import { Check, Errors } from "typebox/value";
|
|
3
|
+
|
|
4
|
+
const schemaArgsSchema = Type.Object(
|
|
5
|
+
{ name: Type.Optional(Type.String({ minLength: 1 })) },
|
|
6
|
+
{ additionalProperties: false },
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
type SchemaArgs = Static<typeof schemaArgsSchema>;
|
|
10
|
+
|
|
11
|
+
export interface EvalSchemaToolInfo {
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly description?: string | undefined;
|
|
14
|
+
readonly parameters?: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RunEvalSchemaOptions {
|
|
18
|
+
readonly listTools: () => readonly EvalSchemaToolInfo[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type EvalSchemaResult =
|
|
22
|
+
| { readonly tools: readonly string[] }
|
|
23
|
+
| { readonly name: string; readonly description: string | undefined; readonly parameters: unknown };
|
|
24
|
+
|
|
25
|
+
class SchemaArgumentsError extends Error {
|
|
26
|
+
readonly name = "SchemaArgumentsError";
|
|
27
|
+
|
|
28
|
+
constructor(summary: string) {
|
|
29
|
+
super(`schema() received invalid arguments: ${summary}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class SchemaUnknownToolError extends Error {
|
|
34
|
+
readonly name = "SchemaUnknownToolError";
|
|
35
|
+
|
|
36
|
+
constructor(requested: string, suggestions: readonly string[]) {
|
|
37
|
+
const hint = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : "";
|
|
38
|
+
super(`schema() found no tool named "${requested}".${hint}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function runEvalSchema(args: unknown, options: RunEvalSchemaOptions): EvalSchemaResult {
|
|
43
|
+
const parsed = parseSchemaArgs(args);
|
|
44
|
+
const tools = options.listTools();
|
|
45
|
+
if (parsed.name === undefined) return { tools: tools.map((tool) => tool.name) };
|
|
46
|
+
|
|
47
|
+
const match = tools.find((tool) => tool.name === parsed.name);
|
|
48
|
+
if (match) return { name: match.name, description: match.description, parameters: match.parameters };
|
|
49
|
+
throw new SchemaUnknownToolError(parsed.name, nearestNames(parsed.name, tools));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseSchemaArgs(value: unknown): SchemaArgs {
|
|
53
|
+
if (Check(schemaArgsSchema, value)) return value;
|
|
54
|
+
const summary = Errors(schemaArgsSchema, value)
|
|
55
|
+
.map((error) => `${error.instancePath || "/"} ${error.message}`)
|
|
56
|
+
.join("; ");
|
|
57
|
+
throw new SchemaArgumentsError(summary || "invalid value");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function nearestNames(requested: string, tools: readonly EvalSchemaToolInfo[]): readonly string[] {
|
|
61
|
+
const needle = requested.toLowerCase();
|
|
62
|
+
return tools
|
|
63
|
+
.map((tool) => tool.name)
|
|
64
|
+
.filter((name) => {
|
|
65
|
+
const candidate = name.toLowerCase();
|
|
66
|
+
return candidate.includes(needle) || needle.includes(candidate);
|
|
67
|
+
})
|
|
68
|
+
.slice(0, 5);
|
|
69
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const SCHEMA_HINT_MAX_CHARS = 1_200;
|
|
2
|
+
const SCHEMA_HINT_MAX_PROPERTIES = 30;
|
|
3
|
+
const DESCRIPTION_MAX_CHARS = 80;
|
|
4
|
+
const ENUM_MAX_VALUES = 8;
|
|
5
|
+
const NESTED_MAX_DEPTH = 2;
|
|
6
|
+
const HINT_HEADER = "Expected parameters:";
|
|
7
|
+
|
|
8
|
+
interface SchemaLike {
|
|
9
|
+
readonly type?: unknown;
|
|
10
|
+
readonly properties?: unknown;
|
|
11
|
+
readonly required?: unknown;
|
|
12
|
+
readonly items?: unknown;
|
|
13
|
+
readonly description?: unknown;
|
|
14
|
+
readonly enum?: unknown;
|
|
15
|
+
readonly const?: unknown;
|
|
16
|
+
readonly oneOf?: unknown;
|
|
17
|
+
readonly anyOf?: unknown;
|
|
18
|
+
readonly allOf?: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function appendSchemaHint(message: string, toolName: string, schema: unknown): string {
|
|
22
|
+
const rendered = renderSchemaHint(toolName, schema);
|
|
23
|
+
if (rendered === undefined) return message;
|
|
24
|
+
return `${message}\n\n${HINT_HEADER}\n${rendered}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function renderSchemaHint(toolName: string, schema: unknown): string | undefined {
|
|
28
|
+
if (!isSchemaLike(schema)) return undefined;
|
|
29
|
+
const lines = renderObjectLines(schema, 0, 1);
|
|
30
|
+
if (lines.length === 0) return undefined;
|
|
31
|
+
return fitLines(lines, `[truncated; call tool_schema(${JSON.stringify(toolName)}) for the full schema]`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function renderObjectLines(schema: SchemaLike, indent: number, depth: number): readonly string[] {
|
|
35
|
+
const required = stringArray(schema.required);
|
|
36
|
+
const entries = propertyEntries(schema.properties);
|
|
37
|
+
if (entries.length === 0 && required.length === 0) return [];
|
|
38
|
+
|
|
39
|
+
const ordered = [
|
|
40
|
+
...entries.filter(([name]) => required.includes(name)),
|
|
41
|
+
...entries.filter(([name]) => !required.includes(name)),
|
|
42
|
+
];
|
|
43
|
+
const pad = " ".repeat(indent + 1);
|
|
44
|
+
const lines: string[] = [];
|
|
45
|
+
if (indent === 0 && required.length > 0) lines.push(`required: ${required.join(", ")}`);
|
|
46
|
+
|
|
47
|
+
const shown = ordered.slice(0, SCHEMA_HINT_MAX_PROPERTIES);
|
|
48
|
+
for (const [name, value] of shown) {
|
|
49
|
+
lines.push(`${pad}${name}${required.includes(name) ? "" : "?"}: ${describeProperty(value)}`);
|
|
50
|
+
lines.push(...nestedLines(value, indent, depth));
|
|
51
|
+
}
|
|
52
|
+
const hidden = ordered.length - shown.length;
|
|
53
|
+
if (hidden > 0) lines.push(`${pad}… ${hidden} more propert${hidden === 1 ? "y" : "ies"}`);
|
|
54
|
+
return lines;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function nestedLines(value: unknown, indent: number, depth: number): readonly string[] {
|
|
58
|
+
if (depth >= NESTED_MAX_DEPTH || !isSchemaLike(value)) return [];
|
|
59
|
+
const nested = isSchemaLike(value.items) ? value.items : value;
|
|
60
|
+
if (propertyEntries(nested.properties).length === 0) return [];
|
|
61
|
+
return renderObjectLines(nested, indent + 1, depth + 1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function describeProperty(value: unknown): string {
|
|
65
|
+
if (!isSchemaLike(value)) return "unknown";
|
|
66
|
+
const parts = [typeLabel(value)];
|
|
67
|
+
const description = firstLine(value.description);
|
|
68
|
+
if (description !== undefined) parts.push(`— ${truncate(description, DESCRIPTION_MAX_CHARS)}`);
|
|
69
|
+
return parts.join(" ");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function typeLabel(schema: SchemaLike): string {
|
|
73
|
+
if (Object.hasOwn(schema, "const")) return JSON.stringify(schema.const);
|
|
74
|
+
const enumValues = schema.enum;
|
|
75
|
+
if (Array.isArray(enumValues) && enumValues.length > 0) return unionLabel(enumValues.map(literal));
|
|
76
|
+
const branches = schema.oneOf ?? schema.anyOf;
|
|
77
|
+
if (Array.isArray(branches) && branches.length > 0) return unionLabel(branches.map(branchLabel));
|
|
78
|
+
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) return branchLabel(schema.allOf[0]);
|
|
79
|
+
|
|
80
|
+
const type = schema.type;
|
|
81
|
+
const base = typeof type === "string" ? type : Array.isArray(type) ? type.filter(isString).join("|") : "unknown";
|
|
82
|
+
if (base !== "array") return base;
|
|
83
|
+
return isSchemaLike(schema.items) ? `array<${typeLabel(schema.items)}>` : "array";
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function unionLabel(values: readonly string[]): string {
|
|
87
|
+
const shown = values.slice(0, ENUM_MAX_VALUES).join(" | ");
|
|
88
|
+
return values.length > ENUM_MAX_VALUES ? `${shown} | … (${values.length - ENUM_MAX_VALUES} more)` : shown;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function branchLabel(value: unknown): string {
|
|
92
|
+
return isSchemaLike(value) ? typeLabel(value) : "unknown";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function literal(value: unknown): string {
|
|
96
|
+
return JSON.stringify(value) ?? String(value);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function fitLines(lines: readonly string[], marker: string): string {
|
|
100
|
+
const budget = SCHEMA_HINT_MAX_CHARS - HINT_HEADER.length - 1;
|
|
101
|
+
const kept: string[] = [];
|
|
102
|
+
let used = 0;
|
|
103
|
+
for (const line of lines) {
|
|
104
|
+
const next = used + line.length + (kept.length === 0 ? 0 : 1);
|
|
105
|
+
if (next > budget - marker.length - 1) {
|
|
106
|
+
kept.push(marker);
|
|
107
|
+
return kept.join("\n");
|
|
108
|
+
}
|
|
109
|
+
kept.push(line);
|
|
110
|
+
used = next;
|
|
111
|
+
}
|
|
112
|
+
return kept.join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function propertyEntries(properties: unknown): readonly (readonly [string, unknown])[] {
|
|
116
|
+
if (typeof properties !== "object" || properties === null || Array.isArray(properties)) return [];
|
|
117
|
+
return Object.entries(properties);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function stringArray(value: unknown): readonly string[] {
|
|
121
|
+
return Array.isArray(value) ? value.filter(isString) : [];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function firstLine(value: unknown): string | undefined {
|
|
125
|
+
if (typeof value !== "string") return undefined;
|
|
126
|
+
const line = value.split("\n", 1)[0]?.trim();
|
|
127
|
+
return line === undefined || line.length === 0 ? undefined : line;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function truncate(value: string, maxChars: number): string {
|
|
131
|
+
return value.length <= maxChars ? value : `${value.slice(0, maxChars)}…`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function isSchemaLike(value: unknown): value is SchemaLike {
|
|
135
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function isString(value: unknown): value is string {
|
|
139
|
+
return typeof value === "string";
|
|
140
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@code-yeongyu/senpi";
|
|
2
|
+
import type { EvalDetachedCellNotification, EvalDetachedCellNotifier } from "../tool/detached-cell-manager.ts";
|
|
3
|
+
|
|
4
|
+
const NON_INTERACTIVE_MODES = new Set(["print", "json"]);
|
|
5
|
+
|
|
6
|
+
export type EvalNotifyMode = "wake" | "next-turn" | "off";
|
|
7
|
+
|
|
8
|
+
export interface EvalNotifierDeps {
|
|
9
|
+
readonly sendUserMessage: (content: string, options?: { deliverAs?: "steer" | "followUp" }) => void;
|
|
10
|
+
readonly getContext: () => ExtensionContext | undefined;
|
|
11
|
+
readonly getMode: () => EvalNotifyMode;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Session-scoped completion injector with the same no-spin guards as terminal notifications. */
|
|
15
|
+
export class EvalNotifier implements EvalDetachedCellNotifier {
|
|
16
|
+
readonly #deps: EvalNotifierDeps;
|
|
17
|
+
readonly #notified = new Set<string>();
|
|
18
|
+
|
|
19
|
+
constructor(deps: EvalNotifierDeps) {
|
|
20
|
+
this.#deps = deps;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Starts a fresh session generation without suppressing reused tool-call ids. */
|
|
24
|
+
reset(): void {
|
|
25
|
+
this.#notified.clear();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
notify(cells: readonly EvalDetachedCellNotification[]): void {
|
|
29
|
+
const mode = this.#deps.getMode();
|
|
30
|
+
if (mode === "off") return;
|
|
31
|
+
const ctx = this.#deps.getContext();
|
|
32
|
+
if (ctx === undefined || NON_INTERACTIVE_MODES.has(ctx.mode) || ctx.model === undefined) return;
|
|
33
|
+
const pending = cells.filter((cell) => !this.#notified.has(cell.cellId));
|
|
34
|
+
if (pending.length === 0) return;
|
|
35
|
+
for (const cell of pending) this.#notified.add(cell.cellId);
|
|
36
|
+
this.#deps.sendUserMessage(pending.map((cell) => cell.content).join("\n\n"), {
|
|
37
|
+
deliverAs: mode === "wake" ? "steer" : "followUp",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -87,8 +87,10 @@ export async function createRuntime(
|
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
export function createExecuteTool(pi: CodemodeRuntimeAPI, activeTools?: ReadonlySet<string>): AgentExecuteTool {
|
|
90
|
+
// A cell names tools directly and cannot run tool_search first, so eval opts in to
|
|
91
|
+
// lazy activation. Eligibility still belongs to the extension that registered the tool.
|
|
90
92
|
const executeTool: AgentExecuteTool = (toolName, params, executeOptions) =>
|
|
91
|
-
pi.executeTool(toolName, params, executeOptions);
|
|
93
|
+
pi.executeTool(toolName, params, { ...executeOptions, activateInactiveTool: true });
|
|
92
94
|
return Object.assign(executeTool, {
|
|
93
95
|
isToolAvailable: (name: string): boolean => activeTools?.has(name) ?? pi.getActiveTools().includes(name),
|
|
94
96
|
});
|