pi-codemcp 1.3.3 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/extensions/index.ts +63 -5
- package/package.json +3 -2
- package/sidecar/executor.py +14 -1
- package/sidecar/gateway.py +77 -2
- package/sidecar/settings.py +7 -0
- package/src/execution-rendering.ts +16 -0
- package/src/jev-router.ts +323 -0
- package/src/lifecycle.ts +8 -2
- package/src/mcp-client.ts +17 -1
- package/src/modal.ts +28 -5
- package/src/settings.ts +24 -4
- package/src/tools.ts +201 -26
package/README.md
CHANGED
|
@@ -5,8 +5,10 @@ Fast, typed, sandboxed **Code Mode for every MCP server configured in Pi**.
|
|
|
5
5
|
Instead of putting every upstream MCP tool definition into the model context, pi-codemcp gives the agent a small interface for discovery, execution, and reuse:
|
|
6
6
|
|
|
7
7
|
- `codemcp_search` ranks capabilities or pages through a compact inventory without loading full schemas.
|
|
8
|
+
- Optional Jev discovery lets the agent route complete MCP tasks on demand, returning relevant calls, composition guidance, and exact contracts through bounded parallel TypeSafe requests.
|
|
8
9
|
- `codemcp_inspect` returns exact typed SDK stubs only for selected calls.
|
|
9
10
|
- `codemcp_execute` runs one sandboxed Python call graph across one or many MCP servers.
|
|
11
|
+
- `codemcp_edit` applies one exact replacement to the previous execution and reruns it without resending the full code.
|
|
10
12
|
- `codemcp_save_chain` turns a repeated call graph into a reusable native Pi tool.
|
|
11
13
|
- `codemcp_manage_chains` lists chains or performs an explicitly confirmed enable, disable, revalidate, or delete.
|
|
12
14
|
|
|
@@ -132,6 +134,7 @@ Settings live at `<agent-dir>/pi-codemcp/settings.json` and can also be edited i
|
|
|
132
134
|
```json
|
|
133
135
|
{
|
|
134
136
|
"version": 2,
|
|
137
|
+
"jevEnabled": false,
|
|
135
138
|
"backgroundWarmup": true,
|
|
136
139
|
"cacheTtlHours": 24,
|
|
137
140
|
"executionTimeoutSeconds": 30,
|
|
@@ -147,6 +150,12 @@ Settings live at `<agent-dir>/pi-codemcp/settings.json` and can also be edited i
|
|
|
147
150
|
|
|
148
151
|
The Python sidecar enforces catalog cache TTL, execution timeout, per-tool timeout, max MCP calls, result size, and disabled-tool policy. The TypeScript Pi layer uses `backgroundWarmup` and `outputLimitKiB` for session warmup and rendered-output truncation; the sidecar still validates those fields so the settings file has one strict shared schema. Version-one files are migrated when loaded, and the removed `outputLineLimit` field is omitted on the next save.
|
|
149
152
|
|
|
153
|
+
### Jev discovery mode
|
|
154
|
+
|
|
155
|
+
Enable Jev in `/codemcp` and provide `TYPESAFE_API_KEY`. This replaces `codemcp_search` with the on-demand `codemcp_route` tool, so ordinary messages have no routing delay. When a task may need MCP, the agent sends the complete task plus every enabled MCP call's name and description to TypeSafe in bounded parallel chunks. Jev scores every call, classifies its workflow role, and checks whether an intermediate model/user checkpoint is required; pi-codemcp merges those raw answers into parallel or dependent composition guidance. The result includes exact typed contracts and directs the agent into the appropriate `codemcp_execute` program.
|
|
156
|
+
|
|
157
|
+
Jev mode is opt-in because routed task text and enabled tool descriptions leave the machine. If the API key is missing, pi-codemcp keeps local `codemcp_search` active. If a Jev route fails, search is activated as an in-session fallback. `TYPESAFE_BASE_URL` and `TYPESAFE_DEFAULT_MODEL` are honored by the official TypeSafe SDK.
|
|
158
|
+
|
|
150
159
|
## Search and execute flow
|
|
151
160
|
|
|
152
161
|
The agent searches for a capability, inspects the selected exact stub when needed, and executes a compact plan. Unscoped searches discover stale or missing server catalogs independently: available servers still return results, while `discovery_failures` explicitly reports unavailable servers. Server-scoped searches remain fail-fast.
|
|
@@ -177,6 +186,8 @@ Incomplete upstream schemas become recursive `JsonValue`, not `Any`; use the pre
|
|
|
177
186
|
|
|
178
187
|
When an oversized value fits the bounded in-memory refinement cache, the failure also returns an opaque `result_ref` and expiry. Pass that reference back as `inputRef` on one follow-up `codemcp_execute`; the retained JSON is exposed as `input`, so code can filter or aggregate it without repeating upstream calls. References expire after five minutes, are valid only in the originating sidecar, and are never persisted.
|
|
179
188
|
|
|
189
|
+
For a small correction, `codemcp_edit` replaces one uniquely matching `oldText` with `newText` in the most recent execution and reruns it with the same `inputRef`. The state is in-memory and disappears when the sidecar restarts. The full call graph runs again, including upstream MCP calls.
|
|
190
|
+
|
|
180
191
|
## Saved-chain CLI flow
|
|
181
192
|
|
|
182
193
|
Saved chains are JSON manifests with sandboxed code plus explicit input/output JSON Schemas. Project-scoped chains live under `<project>/.pi/pi-codemcp/chains`; global chains live under `<agent-dir>/pi-codemcp/chains`.
|
package/extensions/index.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
2
3
|
import {
|
|
3
4
|
CONFIG_DIR_NAME,
|
|
4
5
|
type ExtensionAPI,
|
|
5
6
|
type ExtensionCommandContext,
|
|
6
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { TypeSafeClient } from "@typesafe-ai/sdk";
|
|
7
9
|
import { newCodeMcpTraceId, SavedChainManager } from "../src/chains.js";
|
|
8
10
|
import { setMcpServerEnabled } from "../src/config.js";
|
|
9
11
|
import { summarizeError } from "../src/errors.js";
|
|
12
|
+
import { JevRouter } from "../src/jev-router.js";
|
|
13
|
+
import { readJsonObject, writeJsonObjectAtomically } from "../src/json-file.js";
|
|
10
14
|
import { CodeMcpLifecycle } from "../src/lifecycle.js";
|
|
11
15
|
import type { SidecarClientOptions } from "../src/mcp-client.js";
|
|
12
16
|
import {
|
|
@@ -22,13 +26,21 @@ import {
|
|
|
22
26
|
setEditableSetting,
|
|
23
27
|
setToolEnabled,
|
|
24
28
|
} from "../src/settings.js";
|
|
25
|
-
import { registerCodeMcpTools } from "../src/tools.js";
|
|
29
|
+
import { registerCodeMcpTools, registerJevRouteTool } from "../src/tools.js";
|
|
26
30
|
|
|
27
|
-
export
|
|
31
|
+
export interface CodeMcpExtensionOptions extends SidecarClientOptions {
|
|
32
|
+
jevClient?: TypeSafeClient;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createCodeMcpExtension(options: CodeMcpExtensionOptions = {}) {
|
|
28
36
|
return function codeMcpExtension(pi: ExtensionAPI): void {
|
|
29
|
-
const
|
|
37
|
+
const { jevClient, ...sidecarOptions } = options;
|
|
38
|
+
const lifecycle = new CodeMcpLifecycle(sidecarOptions);
|
|
30
39
|
const chains = new SavedChainManager(pi, lifecycle);
|
|
40
|
+
const routerClient = jevClient ?? createJevClient();
|
|
41
|
+
const jevRouter = routerClient ? new JevRouter(lifecycle, routerClient) : undefined;
|
|
31
42
|
registerCodeMcpTools(pi, lifecycle, chains);
|
|
43
|
+
registerJevRouteTool(pi, () => jevRouter);
|
|
32
44
|
|
|
33
45
|
pi.registerCommand("codemcp", {
|
|
34
46
|
description: "Manage CodeMCP servers, saved chains, tools, and settings",
|
|
@@ -71,7 +83,11 @@ export function createCodeMcpExtension(options: SidecarClientOptions = {}) {
|
|
|
71
83
|
onSetSetting: async (key, value) => {
|
|
72
84
|
const updated = setEditableSetting(lifecycle.loadSettings(), key, value);
|
|
73
85
|
saveCodeMcpSettings(lifecycle.settingsPath, updated);
|
|
74
|
-
await lifecycle.request("reload_settings", {});
|
|
86
|
+
if (key !== "jevEnabled") await lifecycle.request("reload_settings", {});
|
|
87
|
+
setDiscoveryTools(pi, updated.jevEnabled && jevRouter !== undefined);
|
|
88
|
+
if (updated.jevEnabled && !jevRouter) {
|
|
89
|
+
ctx.ui.notify("Jev mode requires TYPESAFE_API_KEY; using local search", "warning");
|
|
90
|
+
}
|
|
75
91
|
return updated;
|
|
76
92
|
},
|
|
77
93
|
onSetChainEnabled: async (chain, enabled) => {
|
|
@@ -97,6 +113,11 @@ export function createCodeMcpExtension(options: SidecarClientOptions = {}) {
|
|
|
97
113
|
});
|
|
98
114
|
|
|
99
115
|
pi.on("session_start", (_event, ctx) => {
|
|
116
|
+
try {
|
|
117
|
+
showChangelogOnce(ctx, join(dirname(lifecycle.settingsPath), "changelog.json"));
|
|
118
|
+
} catch (error) {
|
|
119
|
+
ctx.ui.notify(`CodeMCP changelog failed: ${summarizeError(error)}`, "warning");
|
|
120
|
+
}
|
|
100
121
|
bindProjectChainScope(ctx, lifecycle, chains);
|
|
101
122
|
chains.activatePersisted();
|
|
102
123
|
for (const error of chains.startupErrors) ctx.ui.notify(error, "warning");
|
|
@@ -107,6 +128,11 @@ export function createCodeMcpExtension(options: SidecarClientOptions = {}) {
|
|
|
107
128
|
ctx.ui.notify(`CodeMCP settings failed: ${summarizeError(error)}`, "warning");
|
|
108
129
|
return;
|
|
109
130
|
}
|
|
131
|
+
const useJev = settings.jevEnabled && jevRouter !== undefined;
|
|
132
|
+
setDiscoveryTools(pi, useJev);
|
|
133
|
+
if (settings.jevEnabled && !jevRouter) {
|
|
134
|
+
ctx.ui.notify("Jev mode requires TYPESAFE_API_KEY; using local search", "warning");
|
|
135
|
+
}
|
|
110
136
|
if (!settings.backgroundWarmup) return;
|
|
111
137
|
void lifecycle.warmup().catch((error: unknown) => {
|
|
112
138
|
ctx.ui.notify(`CodeMCP background warmup failed: ${summarizeError(error)}`, "warning");
|
|
@@ -154,6 +180,38 @@ export async function setServerEnabledFromManager(
|
|
|
154
180
|
|
|
155
181
|
export default createCodeMcpExtension();
|
|
156
182
|
|
|
183
|
+
const CHANGELOG_ID = "jev-routing-v1";
|
|
184
|
+
const CHANGELOG_MESSAGE =
|
|
185
|
+
"pi-codemcp update: optional Jev routing is now available! Enable Jev in /codemcp → Settings to let Jev select and compose MCP calls, it's pretty cool";
|
|
186
|
+
|
|
187
|
+
export function showChangelogOnce(
|
|
188
|
+
ctx: Pick<ExtensionCommandContext, "mode" | "ui">,
|
|
189
|
+
path: string,
|
|
190
|
+
): void {
|
|
191
|
+
if (ctx.mode !== "tui") return;
|
|
192
|
+
const state = existsSync(path) ? readJsonObject(path, "CodeMCP changelog state") : {};
|
|
193
|
+
if (state.lastSeen === CHANGELOG_ID) return;
|
|
194
|
+
ctx.ui.notify(CHANGELOG_MESSAGE, "info");
|
|
195
|
+
writeJsonObjectAtomically(path, { lastSeen: CHANGELOG_ID });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function createJevClient(): TypeSafeClient | undefined {
|
|
199
|
+
return process.env.TYPESAFE_API_KEY?.trim() ? new TypeSafeClient() : undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function setDiscoveryTools(pi: ExtensionAPI, useJev: boolean): void {
|
|
203
|
+
const selected = useJev ? "codemcp_route" : "codemcp_search";
|
|
204
|
+
const current = pi.getActiveTools();
|
|
205
|
+
if (
|
|
206
|
+
current.includes(selected) &&
|
|
207
|
+
!current.includes(useJev ? "codemcp_search" : "codemcp_route")
|
|
208
|
+
) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const active = current.filter((name) => name !== "codemcp_search" && name !== "codemcp_route");
|
|
212
|
+
pi.setActiveTools([...active, selected]);
|
|
213
|
+
}
|
|
214
|
+
|
|
157
215
|
export async function promptForProblemReport(
|
|
158
216
|
pi: Pick<ExtensionAPI, "sendUserMessage">,
|
|
159
217
|
ctx: Pick<ExtensionCommandContext, "ui">,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-codemcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Typed, sandboxed Code Mode access to configured MCP servers for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"packageManager": "bun@1.3.10",
|
|
@@ -52,7 +52,8 @@
|
|
|
52
52
|
"image": "https://raw.githubusercontent.com/yolonir/pi-codemcp/main/media/preview.png"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@modelcontextprotocol/sdk": "1.29.0"
|
|
55
|
+
"@modelcontextprotocol/sdk": "1.29.0",
|
|
56
|
+
"@typesafe-ai/sdk": "0.6.0"
|
|
56
57
|
},
|
|
57
58
|
"optionalDependencies": {
|
|
58
59
|
"@manzt/uv-darwin-arm64": "0.8.13",
|
package/sidecar/executor.py
CHANGED
|
@@ -93,11 +93,13 @@ class ExecutionContext:
|
|
|
93
93
|
call_tool: ContextToolCall,
|
|
94
94
|
settings: ExecutionSettings,
|
|
95
95
|
deadline: float,
|
|
96
|
+
progress: ProgressReporter | None = None,
|
|
96
97
|
) -> None:
|
|
97
98
|
self.catalog = catalog
|
|
98
99
|
self.call_tool = call_tool
|
|
99
100
|
self.settings = settings
|
|
100
101
|
self.deadline = deadline
|
|
102
|
+
self.progress = progress
|
|
101
103
|
self.calls_made = 0
|
|
102
104
|
self.chain_calls = 0
|
|
103
105
|
self.metrics = ExecutionMetrics()
|
|
@@ -117,6 +119,7 @@ class ExecutionContext:
|
|
|
117
119
|
|
|
118
120
|
ToolCall = Callable[[str, JsonObject], Awaitable[JsonValue]]
|
|
119
121
|
ContextToolCall = Callable[[str, JsonObject, ExecutionContext], Awaitable[JsonValue]]
|
|
122
|
+
ProgressReporter = Callable[[int, str], Awaitable[None]]
|
|
120
123
|
ExternalFunction = Callable[..., Awaitable[JsonValue]]
|
|
121
124
|
ResultRetainer = Callable[[JsonValue], RetainedResult | None]
|
|
122
125
|
|
|
@@ -291,9 +294,10 @@ class MontyExecutor:
|
|
|
291
294
|
*,
|
|
292
295
|
input_value: JsonValue = None,
|
|
293
296
|
retain_result: ResultRetainer | None = None,
|
|
297
|
+
progress: ProgressReporter | None = None,
|
|
294
298
|
) -> ExecutionResponse:
|
|
295
299
|
async with self._execution_lock:
|
|
296
|
-
context = self._new_context(self.catalog, call_tool)
|
|
300
|
+
context = self._new_context(self.catalog, call_tool, progress=progress)
|
|
297
301
|
return await self._execute_program(
|
|
298
302
|
code,
|
|
299
303
|
context,
|
|
@@ -397,6 +401,8 @@ class MontyExecutor:
|
|
|
397
401
|
self,
|
|
398
402
|
catalog: ToolCatalog,
|
|
399
403
|
call_tool: ContextToolCall,
|
|
404
|
+
*,
|
|
405
|
+
progress: ProgressReporter | None = None,
|
|
400
406
|
) -> ExecutionContext:
|
|
401
407
|
loop = asyncio.get_running_loop()
|
|
402
408
|
return ExecutionContext(
|
|
@@ -404,6 +410,7 @@ class MontyExecutor:
|
|
|
404
410
|
call_tool=call_tool,
|
|
405
411
|
settings=self.settings,
|
|
406
412
|
deadline=loop.time() + self.settings.timeout_seconds,
|
|
413
|
+
progress=progress,
|
|
407
414
|
)
|
|
408
415
|
|
|
409
416
|
async def _execute_program( # ruff:ignore[complex-structure, too-many-statements]
|
|
@@ -502,9 +509,13 @@ class MontyExecutor:
|
|
|
502
509
|
raise ValueError(message) from error
|
|
503
510
|
if spec.kind == "saved_chain":
|
|
504
511
|
context.chain_calls += 1
|
|
512
|
+
if context.progress is not None:
|
|
513
|
+
await context.progress(context.total_calls, spec.call)
|
|
505
514
|
return await context.call_tool(name, validated, context)
|
|
506
515
|
|
|
507
516
|
context.calls_made += 1
|
|
517
|
+
if context.progress is not None:
|
|
518
|
+
await context.progress(context.total_calls, spec.call)
|
|
508
519
|
remaining = context.remaining_seconds()
|
|
509
520
|
if remaining <= 0:
|
|
510
521
|
raise TimeoutError
|
|
@@ -607,6 +618,8 @@ class MontyExecutor:
|
|
|
607
618
|
"max_duration_secs": remaining,
|
|
608
619
|
"max_memory": context.settings.max_memory_bytes,
|
|
609
620
|
}
|
|
621
|
+
if context.progress is not None:
|
|
622
|
+
await context.progress(context.total_calls, "executing")
|
|
610
623
|
runtime_started = time.perf_counter()
|
|
611
624
|
try:
|
|
612
625
|
async with asyncio.timeout(remaining + 0.1):
|
package/sidecar/gateway.py
CHANGED
|
@@ -11,7 +11,7 @@ from contextlib import AsyncExitStack, asynccontextmanager, suppress
|
|
|
11
11
|
from typing import TYPE_CHECKING, Literal, NamedTuple, Protocol, cast
|
|
12
12
|
|
|
13
13
|
import pydantic_monty
|
|
14
|
-
from fastmcp import Client, FastMCP
|
|
14
|
+
from fastmcp import Client, Context, FastMCP
|
|
15
15
|
from fastmcp.mcp_config import RemoteMCPServer, StdioMCPServer
|
|
16
16
|
from pydantic_core import to_json
|
|
17
17
|
from rapidfuzz import fuzz, process
|
|
@@ -33,6 +33,7 @@ from .executor import (
|
|
|
33
33
|
ExecutionFailureInfo,
|
|
34
34
|
ExecutionResponse,
|
|
35
35
|
MontyExecutor,
|
|
36
|
+
ProgressReporter,
|
|
36
37
|
)
|
|
37
38
|
from .mcp_config import NormalizedConfig, load_mcp_json, normalize_mcp_config
|
|
38
39
|
from .models import (
|
|
@@ -206,6 +207,11 @@ class SavedChainHandlers(NamedTuple):
|
|
|
206
207
|
delete: Callable[[str, ChainScope, str], Awaitable[ChainListResponse]]
|
|
207
208
|
|
|
208
209
|
|
|
210
|
+
class _LastExecution(NamedTuple):
|
|
211
|
+
code: str
|
|
212
|
+
input_ref: str | None
|
|
213
|
+
|
|
214
|
+
|
|
209
215
|
class SavedChainRuntime:
|
|
210
216
|
def __init__(self, handlers: SavedChainHandlers) -> None:
|
|
211
217
|
self.handlers = handlers
|
|
@@ -307,6 +313,8 @@ class GatewayRuntime:
|
|
|
307
313
|
)
|
|
308
314
|
)
|
|
309
315
|
self._catalog_lock = asyncio.Lock()
|
|
316
|
+
self._execute_lock = asyncio.Lock()
|
|
317
|
+
self._last_execution: _LastExecution | None = None
|
|
310
318
|
|
|
311
319
|
@classmethod
|
|
312
320
|
def create(
|
|
@@ -594,7 +602,42 @@ class GatewayRuntime:
|
|
|
594
602
|
code: str,
|
|
595
603
|
trace_id: str,
|
|
596
604
|
input_ref: str | None = None,
|
|
605
|
+
progress: ProgressReporter | None = None,
|
|
606
|
+
) -> ExecutionResponse:
|
|
607
|
+
async with self._execute_lock:
|
|
608
|
+
return await self._execute(code, trace_id, input_ref, progress)
|
|
609
|
+
|
|
610
|
+
async def edit_execute(
|
|
611
|
+
self,
|
|
612
|
+
old_text: str,
|
|
613
|
+
new_text: str,
|
|
614
|
+
trace_id: str,
|
|
615
|
+
progress: ProgressReporter | None = None,
|
|
616
|
+
) -> ExecutionResponse:
|
|
617
|
+
async with self._execute_lock:
|
|
618
|
+
previous = self._last_execution
|
|
619
|
+
if previous is None:
|
|
620
|
+
raise ValueError("No previous CodeMCP execution is available in this sidecar")
|
|
621
|
+
if not old_text:
|
|
622
|
+
raise ValueError("old_text must not be empty")
|
|
623
|
+
if old_text == new_text:
|
|
624
|
+
raise ValueError("old_text and new_text must differ")
|
|
625
|
+
matches = previous.code.count(old_text)
|
|
626
|
+
if matches != 1:
|
|
627
|
+
raise ValueError(
|
|
628
|
+
f"old_text must match the previous code exactly once; found {matches} matches"
|
|
629
|
+
)
|
|
630
|
+
code = previous.code.replace(old_text, new_text, 1)
|
|
631
|
+
return await self._execute(code, trace_id, previous.input_ref, progress)
|
|
632
|
+
|
|
633
|
+
async def _execute(
|
|
634
|
+
self,
|
|
635
|
+
code: str,
|
|
636
|
+
trace_id: str,
|
|
637
|
+
input_ref: str | None,
|
|
638
|
+
progress: ProgressReporter | None = None,
|
|
597
639
|
) -> ExecutionResponse:
|
|
640
|
+
self._last_execution = None
|
|
598
641
|
started = time.perf_counter()
|
|
599
642
|
input_bytes = len(code.encode()) + len((input_ref or "").encode())
|
|
600
643
|
input_value: JsonValue = None
|
|
@@ -619,6 +662,7 @@ class GatewayRuntime:
|
|
|
619
662
|
response,
|
|
620
663
|
trace_id,
|
|
621
664
|
)
|
|
665
|
+
self._last_execution = _LastExecution(code, input_ref)
|
|
622
666
|
return response
|
|
623
667
|
discovery_started = time.perf_counter()
|
|
624
668
|
try:
|
|
@@ -642,6 +686,7 @@ class GatewayRuntime:
|
|
|
642
686
|
self._dispatch,
|
|
643
687
|
input_value=input_value,
|
|
644
688
|
retain_result=self.refinement_cache.retain,
|
|
689
|
+
progress=progress,
|
|
645
690
|
)
|
|
646
691
|
except BaseException as error:
|
|
647
692
|
cancelled = isinstance(error, asyncio.CancelledError)
|
|
@@ -655,6 +700,7 @@ class GatewayRuntime:
|
|
|
655
700
|
)
|
|
656
701
|
raise
|
|
657
702
|
self._record_execution("execute", started, input_bytes, response, trace_id)
|
|
703
|
+
self._last_execution = _LastExecution(code, input_ref)
|
|
658
704
|
return response
|
|
659
705
|
|
|
660
706
|
async def _execute_chain(
|
|
@@ -1574,6 +1620,13 @@ def _require_runtime() -> GatewayRuntime:
|
|
|
1574
1620
|
return _runtime_state.runtime
|
|
1575
1621
|
|
|
1576
1622
|
|
|
1623
|
+
def _progress_reporter(ctx: Context) -> ProgressReporter:
|
|
1624
|
+
async def report(total_calls: int, message: str) -> None:
|
|
1625
|
+
await ctx.report_progress(progress=float(total_calls), message=message)
|
|
1626
|
+
|
|
1627
|
+
return report
|
|
1628
|
+
|
|
1629
|
+
|
|
1577
1630
|
def configure_runtime_paths(paths: RuntimePaths | None) -> None:
|
|
1578
1631
|
_runtime_state.paths = paths
|
|
1579
1632
|
|
|
@@ -1673,10 +1726,32 @@ async def set_chain_enabled(
|
|
|
1673
1726
|
async def execute(
|
|
1674
1727
|
trace_id: str,
|
|
1675
1728
|
code: str,
|
|
1729
|
+
ctx: Context,
|
|
1676
1730
|
input_ref: str | None = None,
|
|
1677
1731
|
) -> ExecutionResponse:
|
|
1678
1732
|
"""Type-check and run one sandboxed Python MCP SDK chain."""
|
|
1679
|
-
return await _require_runtime().execute(
|
|
1733
|
+
return await _require_runtime().execute(
|
|
1734
|
+
code,
|
|
1735
|
+
trace_id,
|
|
1736
|
+
input_ref,
|
|
1737
|
+
_progress_reporter(ctx),
|
|
1738
|
+
)
|
|
1739
|
+
|
|
1740
|
+
|
|
1741
|
+
@mcp.tool
|
|
1742
|
+
async def edit_execute(
|
|
1743
|
+
trace_id: str,
|
|
1744
|
+
old_text: str,
|
|
1745
|
+
new_text: str,
|
|
1746
|
+
ctx: Context,
|
|
1747
|
+
) -> ExecutionResponse:
|
|
1748
|
+
"""Patch and rerun the last sandbox execution."""
|
|
1749
|
+
return await _require_runtime().edit_execute(
|
|
1750
|
+
old_text,
|
|
1751
|
+
new_text,
|
|
1752
|
+
trace_id,
|
|
1753
|
+
_progress_reporter(ctx),
|
|
1754
|
+
)
|
|
1680
1755
|
|
|
1681
1756
|
|
|
1682
1757
|
@mcp.tool
|
package/sidecar/settings.py
CHANGED
|
@@ -26,6 +26,7 @@ class CodeMcpSettings(BaseModel):
|
|
|
26
26
|
)
|
|
27
27
|
|
|
28
28
|
version: Literal[2] = 2
|
|
29
|
+
jev_enabled: bool = False
|
|
29
30
|
background_warmup: bool = True
|
|
30
31
|
cache_ttl_hours: int = Field(default=24, ge=0, le=720)
|
|
31
32
|
execution_timeout_seconds: int = Field(default=30, ge=1, le=300)
|
|
@@ -60,4 +61,10 @@ def load_settings(path: Path) -> CodeMcpSettings:
|
|
|
60
61
|
if isinstance(value, dict) and value.get("version", 1) == 1:
|
|
61
62
|
value = {key: item for key, item in value.items() if key != "outputLineLimit"}
|
|
62
63
|
value["version"] = 2
|
|
64
|
+
if isinstance(value, dict) and "discoveryMode" in value:
|
|
65
|
+
mode = value.pop("discoveryMode")
|
|
66
|
+
if not isinstance(mode, str) or mode not in {"search", "jev"}:
|
|
67
|
+
value["discoveryMode"] = mode
|
|
68
|
+
else:
|
|
69
|
+
value.setdefault("jevEnabled", mode == "jev")
|
|
63
70
|
return CodeMcpSettings.model_validate(value)
|
|
@@ -16,6 +16,12 @@ interface RenderResult {
|
|
|
16
16
|
details?: unknown;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** Details sent with partial updates while an execution is running. */
|
|
20
|
+
export interface ExecutionProgressDetails {
|
|
21
|
+
callsMade: number;
|
|
22
|
+
currentCall?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
19
25
|
interface ExecutionRendererOptions {
|
|
20
26
|
partialText?: string;
|
|
21
27
|
expandDescription?: string;
|
|
@@ -28,6 +34,16 @@ export function renderExecutionResult(
|
|
|
28
34
|
options: ExecutionRendererOptions = {},
|
|
29
35
|
): Text {
|
|
30
36
|
if (state.isPartial) {
|
|
37
|
+
const progress = result.details as ExecutionProgressDetails | undefined;
|
|
38
|
+
if (typeof progress?.callsMade === "number") {
|
|
39
|
+
const calls = progress.callsMade;
|
|
40
|
+
const current = progress.currentCall ? ` · ${progress.currentCall}` : "";
|
|
41
|
+
return new Text(
|
|
42
|
+
`\n${theme.fg("warning", `Executing · ${calls} MCP ${calls === 1 ? "call" : "calls"}${current}...`)}`,
|
|
43
|
+
0,
|
|
44
|
+
0,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
31
47
|
return new Text(
|
|
32
48
|
`\n${theme.fg("warning", options.partialText ?? "Preflight check, then execution...")}`,
|
|
33
49
|
0,
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { choice, noul, type Questions, type TypeSafeClient } from "@typesafe-ai/sdk";
|
|
3
|
+
import type { CodeMcpLifecycle } from "./lifecycle.js";
|
|
4
|
+
|
|
5
|
+
const TOOL_RELEVANCE_THRESHOLD = 0.65;
|
|
6
|
+
const NOUL_THRESHOLD = 0.5;
|
|
7
|
+
// ponytail: bound injected schemas; raise only if routing evals show recall loss.
|
|
8
|
+
const MAX_SELECTED_TOOLS = 8;
|
|
9
|
+
const CATALOG_PAGE_SIZE = 20;
|
|
10
|
+
const JEV_CHUNK_SIZE = 40;
|
|
11
|
+
|
|
12
|
+
type ToolRole = "source" | "enrichment" | "sink" | "standalone" | "unspecified";
|
|
13
|
+
const TOOL_ROLE_BY_CHOICE: Readonly<Record<string, ToolRole>> = {
|
|
14
|
+
source: "source",
|
|
15
|
+
enrichment: "enrichment",
|
|
16
|
+
sink: "sink",
|
|
17
|
+
standalone: "standalone",
|
|
18
|
+
irrelevant: "unspecified",
|
|
19
|
+
};
|
|
20
|
+
type WorkflowShape = "single_call" | "parallel" | "pipeline" | "mixed";
|
|
21
|
+
|
|
22
|
+
interface CatalogTool {
|
|
23
|
+
call: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface IndexedTool {
|
|
28
|
+
index: number;
|
|
29
|
+
tool: CatalogTool;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ChunkResult {
|
|
33
|
+
tools: JevSelectedTool[];
|
|
34
|
+
needsAnyTool?: number;
|
|
35
|
+
needsCheckpoint?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface JevSelectedTool extends CatalogTool {
|
|
39
|
+
relevance: number;
|
|
40
|
+
role: ToolRole;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface JevRoute {
|
|
44
|
+
prompt: string;
|
|
45
|
+
selected: JevSelectedTool[];
|
|
46
|
+
needsAnyTool: number;
|
|
47
|
+
workflowShape: WorkflowShape;
|
|
48
|
+
needsCheckpoint: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class JevRouter {
|
|
52
|
+
constructor(
|
|
53
|
+
private readonly lifecycle: Pick<CodeMcpLifecycle, "request">,
|
|
54
|
+
private readonly client: TypeSafeClient,
|
|
55
|
+
) {}
|
|
56
|
+
|
|
57
|
+
async route(task: string, recentContext = "", signal?: AbortSignal): Promise<JevRoute> {
|
|
58
|
+
const catalog = await this.loadCatalog(signal);
|
|
59
|
+
if (catalog.length === 0) return emptyRoute();
|
|
60
|
+
|
|
61
|
+
const chunks = chunk(
|
|
62
|
+
catalog.map((tool, index) => ({ tool, index })),
|
|
63
|
+
JEV_CHUNK_SIZE,
|
|
64
|
+
);
|
|
65
|
+
const results = await Promise.all(
|
|
66
|
+
chunks.map((tools, index) =>
|
|
67
|
+
this.evaluateChunk(task, recentContext, tools, index === 0, signal),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
const needsAnyTool = results[0]?.needsAnyTool ?? 0;
|
|
71
|
+
const needsCheckpoint = results[0]?.needsCheckpoint ?? 0;
|
|
72
|
+
const ranked = results
|
|
73
|
+
.flatMap((result) => result.tools)
|
|
74
|
+
.sort(
|
|
75
|
+
(left, right) => right.relevance - left.relevance || left.call.localeCompare(right.call),
|
|
76
|
+
);
|
|
77
|
+
const selected =
|
|
78
|
+
needsAnyTool >= NOUL_THRESHOLD
|
|
79
|
+
? ranked
|
|
80
|
+
.filter((tool) => tool.relevance >= TOOL_RELEVANCE_THRESHOLD)
|
|
81
|
+
.slice(0, MAX_SELECTED_TOOLS)
|
|
82
|
+
: [];
|
|
83
|
+
const workflowShape = workflowShapeFor(selected);
|
|
84
|
+
if (selected.length === 0) {
|
|
85
|
+
return { ...emptyRoute(), needsAnyTool, needsCheckpoint, workflowShape };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const inspection = await this.lifecycle.request(
|
|
89
|
+
"inspect",
|
|
90
|
+
{
|
|
91
|
+
calls: selected.map((tool) => tool.call),
|
|
92
|
+
trace_id: `jev-${randomUUID()}`,
|
|
93
|
+
},
|
|
94
|
+
signal,
|
|
95
|
+
);
|
|
96
|
+
const prelude = typeof inspection.prelude === "string" ? inspection.prelude : "";
|
|
97
|
+
const stubs = Array.isArray(inspection.results)
|
|
98
|
+
? inspection.results.flatMap((item) => {
|
|
99
|
+
if (!isRecord(item) || typeof item.stub !== "string") return [];
|
|
100
|
+
return [item.stub];
|
|
101
|
+
})
|
|
102
|
+
: [];
|
|
103
|
+
if (stubs.length !== selected.length) {
|
|
104
|
+
throw new Error("CodeMCP inspect returned incomplete Jev-selected contracts");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
prompt: jevPrompt(
|
|
109
|
+
selected,
|
|
110
|
+
workflowShape,
|
|
111
|
+
needsCheckpoint,
|
|
112
|
+
[prelude, ...stubs].filter(Boolean).join("\n\n"),
|
|
113
|
+
),
|
|
114
|
+
selected,
|
|
115
|
+
needsAnyTool,
|
|
116
|
+
workflowShape,
|
|
117
|
+
needsCheckpoint,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private async evaluateChunk(
|
|
122
|
+
task: string,
|
|
123
|
+
recentContext: string,
|
|
124
|
+
tools: IndexedTool[],
|
|
125
|
+
includeTaskQuestions: boolean,
|
|
126
|
+
signal?: AbortSignal,
|
|
127
|
+
): Promise<ChunkResult> {
|
|
128
|
+
const questions: Questions = {};
|
|
129
|
+
if (includeTaskQuestions) {
|
|
130
|
+
questions.needs_any_tool = noul(
|
|
131
|
+
"Does satisfying the user's request require at least one configured external-service or saved-workflow tool?",
|
|
132
|
+
{
|
|
133
|
+
true: "The request needs current, private, or external state, or asks for an external action.",
|
|
134
|
+
false: "Explanation, reasoning, or local coding tools can fully satisfy the request.",
|
|
135
|
+
},
|
|
136
|
+
);
|
|
137
|
+
questions.needs_checkpoint = noul(
|
|
138
|
+
"Must the agent inspect an intermediate result, make a semantic decision, or obtain user approval before the next external call?",
|
|
139
|
+
{
|
|
140
|
+
true: "A model or user decision is required between tool stages.",
|
|
141
|
+
false: "One deterministic CodeMCP program can safely run the complete workflow.",
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
for (const { index, tool } of tools) {
|
|
146
|
+
const toolDescription = {
|
|
147
|
+
call: tool.call,
|
|
148
|
+
description: tool.description ?? tool.call,
|
|
149
|
+
};
|
|
150
|
+
questions[`tool_${index}`] = noul(
|
|
151
|
+
{
|
|
152
|
+
question:
|
|
153
|
+
"Is this exact tool necessary to satisfy an explicit part of the user's request?",
|
|
154
|
+
tool: toolDescription,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
true: "The minimal correct workflow needs this capability.",
|
|
158
|
+
false: "The tool is unrelated, redundant, optional, or merely adjacent.",
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
questions[`role_${index}`] = choice(
|
|
162
|
+
{
|
|
163
|
+
question: "What role should this tool have in the minimal requested workflow?",
|
|
164
|
+
tool: toolDescription,
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
source: "Retrieves initial data.",
|
|
168
|
+
enrichment: "Retrieves data using an earlier result.",
|
|
169
|
+
sink: "Performs a downstream action using earlier results.",
|
|
170
|
+
standalone: "Independently completes one requested action.",
|
|
171
|
+
irrelevant: "Should not be used for this task.",
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const response = await this.client.systemOne(
|
|
177
|
+
{
|
|
178
|
+
state: {
|
|
179
|
+
task,
|
|
180
|
+
...(recentContext ? { recent_context: recentContext } : {}),
|
|
181
|
+
},
|
|
182
|
+
questions,
|
|
183
|
+
},
|
|
184
|
+
signal ? { signal } : undefined,
|
|
185
|
+
);
|
|
186
|
+
return {
|
|
187
|
+
tools: tools.map(({ index, tool }) => ({
|
|
188
|
+
...tool,
|
|
189
|
+
relevance: noulValue(response.answers[`tool_${index}`]),
|
|
190
|
+
role: toolRoleValue(response.answers[`role_${index}`]),
|
|
191
|
+
})),
|
|
192
|
+
...(includeTaskQuestions
|
|
193
|
+
? {
|
|
194
|
+
needsAnyTool: noulValue(response.answers.needs_any_tool),
|
|
195
|
+
needsCheckpoint: noulValue(response.answers.needs_checkpoint),
|
|
196
|
+
}
|
|
197
|
+
: {}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async loadCatalog(signal?: AbortSignal): Promise<CatalogTool[]> {
|
|
202
|
+
const tools: CatalogTool[] = [];
|
|
203
|
+
let cursor = 0;
|
|
204
|
+
while (true) {
|
|
205
|
+
const page = await this.lifecycle.request(
|
|
206
|
+
"search",
|
|
207
|
+
{
|
|
208
|
+
mode: "inventory",
|
|
209
|
+
detail: "names",
|
|
210
|
+
limit: CATALOG_PAGE_SIZE,
|
|
211
|
+
cursor,
|
|
212
|
+
trace_id: `jev-${randomUUID()}`,
|
|
213
|
+
},
|
|
214
|
+
signal,
|
|
215
|
+
);
|
|
216
|
+
if (Array.isArray(page.results)) {
|
|
217
|
+
for (const item of page.results) {
|
|
218
|
+
if (!isRecord(item) || typeof item.call !== "string") continue;
|
|
219
|
+
tools.push({
|
|
220
|
+
call: item.call,
|
|
221
|
+
...(typeof item.description === "string" ? { description: item.description } : {}),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (typeof page.next_cursor !== "number") break;
|
|
226
|
+
if (page.next_cursor <= cursor) throw new Error("CodeMCP inventory cursor did not advance");
|
|
227
|
+
cursor = page.next_cursor;
|
|
228
|
+
}
|
|
229
|
+
return tools;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function emptyRoute(): JevRoute {
|
|
234
|
+
return {
|
|
235
|
+
prompt: "Jev found no configured MCP tool relevant to this request.",
|
|
236
|
+
selected: [],
|
|
237
|
+
needsAnyTool: 0,
|
|
238
|
+
workflowShape: "single_call",
|
|
239
|
+
needsCheckpoint: 0,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function workflowShapeFor(selected: JevSelectedTool[]): WorkflowShape {
|
|
244
|
+
if (selected.length <= 1) return "single_call";
|
|
245
|
+
const independent = selected.filter(
|
|
246
|
+
(tool) => tool.role === "source" || tool.role === "standalone",
|
|
247
|
+
).length;
|
|
248
|
+
const downstream = selected.some((tool) => tool.role === "enrichment" || tool.role === "sink");
|
|
249
|
+
if (!downstream) return "parallel";
|
|
250
|
+
return independent > 1 ? "mixed" : "pipeline";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function jevPrompt(
|
|
254
|
+
selected: JevSelectedTool[],
|
|
255
|
+
workflowShape: WorkflowShape,
|
|
256
|
+
needsCheckpoint: number,
|
|
257
|
+
contracts: string,
|
|
258
|
+
): string {
|
|
259
|
+
const roles = selected.map((tool) => `- ${tool.call}: ${tool.role}`).join("\n");
|
|
260
|
+
const execution =
|
|
261
|
+
needsCheckpoint >= NOUL_THRESHOLD
|
|
262
|
+
? "Run only the first stage in codemcp_execute, return compact decision data, then preserve a model/user checkpoint before downstream calls."
|
|
263
|
+
: workflowInstruction(workflowShape);
|
|
264
|
+
return [
|
|
265
|
+
`Jev selected these MCP calls:\n${roles}`,
|
|
266
|
+
`Composition: ${workflowShape}.`,
|
|
267
|
+
`Execution recommendation: ${execution}`,
|
|
268
|
+
"Use these exact typed SDK contracts:",
|
|
269
|
+
"```python",
|
|
270
|
+
contracts,
|
|
271
|
+
"```",
|
|
272
|
+
].join("\n\n");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function workflowInstruction(shape: WorkflowShape): string {
|
|
276
|
+
switch (shape) {
|
|
277
|
+
case "single_call":
|
|
278
|
+
return "Write and run one minimal codemcp_execute program using the selected call.";
|
|
279
|
+
case "parallel":
|
|
280
|
+
return "Write and run one codemcp_execute program using asyncio.gather for independent calls, then combine their results locally.";
|
|
281
|
+
case "pipeline":
|
|
282
|
+
return "Write and run one codemcp_execute program that passes earlier outputs into dependent calls.";
|
|
283
|
+
case "mixed":
|
|
284
|
+
return "Write and run one codemcp_execute program that gathers independent source calls, then feeds their results into downstream calls.";
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function toolRoleValue(answer: unknown): ToolRole {
|
|
289
|
+
const role = TOOL_ROLE_BY_CHOICE[choiceValue(answer)];
|
|
290
|
+
if (!role) throw new Error("TypeSafe returned an invalid Jev tool role");
|
|
291
|
+
return role;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function choiceValue(answer: unknown): string {
|
|
295
|
+
if (!isRecord(answer) || answer.type !== "choice" || typeof answer.choice !== "string") {
|
|
296
|
+
throw new Error("TypeSafe returned an invalid Jev routing choice");
|
|
297
|
+
}
|
|
298
|
+
return answer.choice;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function noulValue(answer: unknown): number {
|
|
302
|
+
if (
|
|
303
|
+
!isRecord(answer) ||
|
|
304
|
+
answer.type !== "noul" ||
|
|
305
|
+
typeof answer.noul !== "number" ||
|
|
306
|
+
!Number.isFinite(answer.noul)
|
|
307
|
+
) {
|
|
308
|
+
throw new Error("TypeSafe returned an invalid Jev routing answer");
|
|
309
|
+
}
|
|
310
|
+
return answer.noul;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function chunk<T>(values: T[], size: number): T[][] {
|
|
314
|
+
const chunks: T[][] = [];
|
|
315
|
+
for (let index = 0; index < values.length; index += size) {
|
|
316
|
+
chunks.push(values.slice(index, index + size));
|
|
317
|
+
}
|
|
318
|
+
return chunks;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
322
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
323
|
+
}
|
package/src/lifecycle.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
SidecarClient,
|
|
3
|
+
type SidecarClientOptions,
|
|
4
|
+
type SidecarProgressCallback,
|
|
5
|
+
type SidecarToolName,
|
|
6
|
+
} from "./mcp-client.js";
|
|
2
7
|
import { type CodeMcpSettings, loadCodeMcpSettings } from "./settings.js";
|
|
3
8
|
|
|
4
9
|
export class CodeMcpLifecycle {
|
|
@@ -37,9 +42,10 @@ export class CodeMcpLifecycle {
|
|
|
37
42
|
name: SidecarToolName,
|
|
38
43
|
args: Record<string, unknown>,
|
|
39
44
|
signal?: AbortSignal,
|
|
45
|
+
onProgress?: SidecarProgressCallback,
|
|
40
46
|
): Promise<Record<string, unknown>> {
|
|
41
47
|
await this.reloadBarrier;
|
|
42
|
-
return this.sidecar.call(name, args, signal);
|
|
48
|
+
return this.sidecar.call(name, args, signal, onProgress);
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
async warmup(): Promise<void> {
|
package/src/mcp-client.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type SidecarToolName =
|
|
|
23
23
|
| "discover"
|
|
24
24
|
| "reload_settings"
|
|
25
25
|
| "execute"
|
|
26
|
+
| "edit_execute"
|
|
26
27
|
| "save_chain"
|
|
27
28
|
| "list_chains"
|
|
28
29
|
| "execute_chain"
|
|
@@ -34,6 +35,7 @@ export type SidecarToolName =
|
|
|
34
35
|
|
|
35
36
|
const LONG_RUNNING_TOOLS = new Set<SidecarToolName>([
|
|
36
37
|
"execute",
|
|
38
|
+
"edit_execute",
|
|
37
39
|
"save_chain",
|
|
38
40
|
"execute_chain",
|
|
39
41
|
"revalidate_chain",
|
|
@@ -64,6 +66,14 @@ export function sidecarToolTimeoutMs(
|
|
|
64
66
|
return DEFAULT_TOOL_TIMEOUT_MS;
|
|
65
67
|
}
|
|
66
68
|
|
|
69
|
+
export interface SidecarProgress {
|
|
70
|
+
progress: number;
|
|
71
|
+
total?: number | undefined;
|
|
72
|
+
message?: string | undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type SidecarProgressCallback = (progress: SidecarProgress) => void;
|
|
76
|
+
|
|
67
77
|
export interface SidecarClientOptions {
|
|
68
78
|
packageRoot?: string;
|
|
69
79
|
agentDir?: string;
|
|
@@ -151,7 +161,12 @@ export class SidecarClient {
|
|
|
151
161
|
return this.client !== undefined && this.transport?.pid !== null;
|
|
152
162
|
}
|
|
153
163
|
|
|
154
|
-
async call(
|
|
164
|
+
async call(
|
|
165
|
+
name: SidecarToolName,
|
|
166
|
+
args: JsonObject,
|
|
167
|
+
signal?: AbortSignal,
|
|
168
|
+
onProgress?: SidecarProgressCallback,
|
|
169
|
+
): Promise<JsonObject> {
|
|
155
170
|
await this.ensureStarted(signal);
|
|
156
171
|
const client = this.client;
|
|
157
172
|
if (!client) throw new Error("Sidecar client failed to initialize");
|
|
@@ -163,6 +178,7 @@ export class SidecarClient {
|
|
|
163
178
|
const result = await client.callTool({ name, arguments: args }, undefined, {
|
|
164
179
|
timeout,
|
|
165
180
|
...(signal === undefined ? {} : { signal }),
|
|
181
|
+
...(onProgress === undefined ? {} : { onprogress: onProgress }),
|
|
166
182
|
});
|
|
167
183
|
|
|
168
184
|
if (result.isError) {
|
package/src/modal.ts
CHANGED
|
@@ -13,7 +13,12 @@ import {
|
|
|
13
13
|
} from "@earendil-works/pi-tui";
|
|
14
14
|
import type { ChainScope, SavedChainView } from "./chains.js";
|
|
15
15
|
import { summarizeError } from "./errors.js";
|
|
16
|
-
import
|
|
16
|
+
import {
|
|
17
|
+
type CodeMcpSettings,
|
|
18
|
+
type EditableSettingKey,
|
|
19
|
+
type EditableSettingValue,
|
|
20
|
+
setEditableSetting,
|
|
21
|
+
} from "./settings.js";
|
|
17
22
|
|
|
18
23
|
export interface ToolModalState {
|
|
19
24
|
name: string;
|
|
@@ -136,13 +141,22 @@ const OVERLAY_OPTIONS = {
|
|
|
136
141
|
minWidth: 72,
|
|
137
142
|
maxHeight: "85%",
|
|
138
143
|
} as const;
|
|
139
|
-
|
|
140
144
|
const PROBLEM_REPORT_LABEL = "Extension is broken!";
|
|
141
145
|
const PROBLEM_REPORT_DESCRIPTION =
|
|
142
146
|
"Well, that sucks. With this button you can ask the agent to describe the problem and prepare a GitHub issue for review. The goal is to make pi-codemcp usable for everyone, don't be lazy - submit an issue. Don't worry, you will see all prompts, this is a transparent process.";
|
|
143
147
|
const PROBLEM_REPORT_SHORTCUT = "Report issue: R";
|
|
144
148
|
|
|
145
149
|
const SETTING_DEFINITIONS: SettingDefinition[] = [
|
|
150
|
+
{
|
|
151
|
+
key: "jevEnabled",
|
|
152
|
+
label: "Enable Jev",
|
|
153
|
+
description:
|
|
154
|
+
"Let the agent call Jev on demand to select and compose relevant MCP contracts instead of local search. Requires TYPESAFE_API_KEY and sends routed tasks and enabled tool descriptions to TypeSafe.",
|
|
155
|
+
choices: [
|
|
156
|
+
{ value: false, label: "false" },
|
|
157
|
+
{ value: true, label: "true" },
|
|
158
|
+
],
|
|
159
|
+
},
|
|
146
160
|
{
|
|
147
161
|
key: "backgroundWarmup",
|
|
148
162
|
label: "Background warmup",
|
|
@@ -327,6 +341,7 @@ class ServerManagerModal implements Component, Focusable {
|
|
|
327
341
|
private selectedToolIndex = 0;
|
|
328
342
|
private selectedChainIndex = 0;
|
|
329
343
|
private selectedSettingIndex = 0;
|
|
344
|
+
private settingBusy = false;
|
|
330
345
|
private settingsError: string | undefined;
|
|
331
346
|
private _focused = false;
|
|
332
347
|
|
|
@@ -1005,8 +1020,9 @@ class ServerManagerModal implements Component, Focusable {
|
|
|
1005
1020
|
|
|
1006
1021
|
private cycleSelectedSetting(direction: -1 | 1): void {
|
|
1007
1022
|
const definition = SETTING_DEFINITIONS[this.selectedSettingIndex];
|
|
1008
|
-
if (!definition) return;
|
|
1009
|
-
const
|
|
1023
|
+
if (!definition || this.settingBusy) return;
|
|
1024
|
+
const previous = this.options.settings;
|
|
1025
|
+
const current = previous[definition.key];
|
|
1010
1026
|
const currentIndex = Math.max(
|
|
1011
1027
|
0,
|
|
1012
1028
|
definition.choices.findIndex((choice) => choice.value === current),
|
|
@@ -1014,16 +1030,23 @@ class ServerManagerModal implements Component, Focusable {
|
|
|
1014
1030
|
const choice =
|
|
1015
1031
|
definition.choices[cycleIndex(currentIndex, direction, definition.choices.length)];
|
|
1016
1032
|
if (!choice) return;
|
|
1033
|
+
this.settingBusy = true;
|
|
1017
1034
|
this.settingsError = undefined;
|
|
1035
|
+
this.options.settings = setEditableSetting(previous, definition.key, choice.value);
|
|
1036
|
+
this.requestRender();
|
|
1018
1037
|
void this.options
|
|
1019
1038
|
.onSetSetting(definition.key, choice.value)
|
|
1020
1039
|
.then((settings) => {
|
|
1021
1040
|
this.options.settings = settings;
|
|
1022
1041
|
})
|
|
1023
1042
|
.catch((error: unknown) => {
|
|
1043
|
+
this.options.settings = previous;
|
|
1024
1044
|
this.settingsError = summarizeError(error);
|
|
1025
1045
|
})
|
|
1026
|
-
.finally(() =>
|
|
1046
|
+
.finally(() => {
|
|
1047
|
+
this.settingBusy = false;
|
|
1048
|
+
this.requestRender();
|
|
1049
|
+
});
|
|
1027
1050
|
}
|
|
1028
1051
|
|
|
1029
1052
|
private focusProblemReport(): void {
|
package/src/settings.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { readJsonObject, requireJsonObject, writeJsonObjectAtomically } from "./json-file.js";
|
|
3
3
|
|
|
4
4
|
export interface CodeMcpSettings {
|
|
5
|
+
jevEnabled: boolean;
|
|
5
6
|
backgroundWarmup: boolean;
|
|
6
7
|
cacheTtlHours: number;
|
|
7
8
|
executionTimeoutSeconds: number;
|
|
@@ -16,6 +17,7 @@ export type EditableSettingKey = Exclude<keyof CodeMcpSettings, "disabledTools">
|
|
|
16
17
|
export type EditableSettingValue = boolean | number;
|
|
17
18
|
|
|
18
19
|
export const DEFAULT_CODEMCP_SETTINGS: Readonly<CodeMcpSettings> = {
|
|
20
|
+
jevEnabled: false,
|
|
19
21
|
backgroundWarmup: true,
|
|
20
22
|
cacheTtlHours: 24,
|
|
21
23
|
executionTimeoutSeconds: 30,
|
|
@@ -28,6 +30,7 @@ export const DEFAULT_CODEMCP_SETTINGS: Readonly<CodeMcpSettings> = {
|
|
|
28
30
|
|
|
29
31
|
const ALLOWED_KEYS = new Set([
|
|
30
32
|
"version",
|
|
33
|
+
"jevEnabled",
|
|
31
34
|
"backgroundWarmup",
|
|
32
35
|
"cacheTtlHours",
|
|
33
36
|
"executionTimeoutSeconds",
|
|
@@ -45,16 +48,18 @@ export function loadCodeMcpSettings(path: string): CodeMcpSettings {
|
|
|
45
48
|
if (version !== 1 && version !== 2) {
|
|
46
49
|
throw new Error(`Unsupported CodeMCP settings version: ${String(version)}`);
|
|
47
50
|
}
|
|
48
|
-
const
|
|
51
|
+
const versionMigrated =
|
|
49
52
|
version === 1
|
|
50
53
|
? Object.fromEntries(Object.entries(root).filter(([key]) => key !== "outputLineLimit"))
|
|
51
54
|
: root;
|
|
55
|
+
const migrated = migrateDiscoveryMode(versionMigrated);
|
|
52
56
|
const unknown = Object.keys(migrated).filter((key) => !ALLOWED_KEYS.has(key));
|
|
53
57
|
if (unknown.length > 0) {
|
|
54
58
|
throw new Error(`Unknown CodeMCP settings: ${unknown.join(", ")}`);
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
return {
|
|
62
|
+
jevEnabled: booleanSetting(migrated, "jevEnabled"),
|
|
58
63
|
backgroundWarmup: booleanSetting(migrated, "backgroundWarmup"),
|
|
59
64
|
cacheTtlHours: integerSetting(migrated, "cacheTtlHours", 0, 720),
|
|
60
65
|
executionTimeoutSeconds: integerSetting(migrated, "executionTimeoutSeconds", 1, 300),
|
|
@@ -69,6 +74,7 @@ export function loadCodeMcpSettings(path: string): CodeMcpSettings {
|
|
|
69
74
|
export function saveCodeMcpSettings(path: string, settings: CodeMcpSettings): void {
|
|
70
75
|
writeJsonObjectAtomically(path, {
|
|
71
76
|
version: 2,
|
|
77
|
+
jevEnabled: settings.jevEnabled,
|
|
72
78
|
backgroundWarmup: settings.backgroundWarmup,
|
|
73
79
|
cacheTtlHours: settings.cacheTtlHours,
|
|
74
80
|
executionTimeoutSeconds: settings.executionTimeoutSeconds,
|
|
@@ -85,7 +91,7 @@ export function setEditableSetting(
|
|
|
85
91
|
key: EditableSettingKey,
|
|
86
92
|
value: EditableSettingValue,
|
|
87
93
|
): CodeMcpSettings {
|
|
88
|
-
if (key === "backgroundWarmup") {
|
|
94
|
+
if (key === "jevEnabled" || key === "backgroundWarmup") {
|
|
89
95
|
if (typeof value !== "boolean") throw new TypeError(`${key} must be a boolean`);
|
|
90
96
|
return { ...settings, [key]: value };
|
|
91
97
|
}
|
|
@@ -112,7 +118,21 @@ function cloneDefaults(): CodeMcpSettings {
|
|
|
112
118
|
return { ...DEFAULT_CODEMCP_SETTINGS, disabledTools: {} };
|
|
113
119
|
}
|
|
114
120
|
|
|
115
|
-
function
|
|
121
|
+
function migrateDiscoveryMode(root: Record<string, unknown>): Record<string, unknown> {
|
|
122
|
+
if (root.discoveryMode === undefined) return root;
|
|
123
|
+
if (root.discoveryMode !== "search" && root.discoveryMode !== "jev") {
|
|
124
|
+
throw new TypeError("discoveryMode must be search or jev");
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
...Object.fromEntries(Object.entries(root).filter(([key]) => key !== "discoveryMode")),
|
|
128
|
+
jevEnabled: root.jevEnabled ?? root.discoveryMode === "jev",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function booleanSetting(
|
|
133
|
+
root: Record<string, unknown>,
|
|
134
|
+
key: "jevEnabled" | "backgroundWarmup",
|
|
135
|
+
): boolean {
|
|
116
136
|
const value = root[key] ?? DEFAULT_CODEMCP_SETTINGS[key];
|
|
117
137
|
if (typeof value !== "boolean") throw new TypeError(`${key} must be a boolean`);
|
|
118
138
|
return value;
|
|
@@ -120,7 +140,7 @@ function booleanSetting(root: Record<string, unknown>, key: "backgroundWarmup"):
|
|
|
120
140
|
|
|
121
141
|
function integerSetting(
|
|
122
142
|
root: Record<string, unknown>,
|
|
123
|
-
key: Exclude<EditableSettingKey, "backgroundWarmup">,
|
|
143
|
+
key: Exclude<EditableSettingKey, "jevEnabled" | "backgroundWarmup">,
|
|
124
144
|
minimum: number,
|
|
125
145
|
maximum: number,
|
|
126
146
|
): number {
|
package/src/tools.ts
CHANGED
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
previewExecutionValue,
|
|
13
13
|
renderExecutionResult,
|
|
14
14
|
} from "./execution-rendering.js";
|
|
15
|
+
import type { JevRouter } from "./jev-router.js";
|
|
15
16
|
import type { CodeMcpLifecycle } from "./lifecycle.js";
|
|
17
|
+
import type { SidecarProgress } from "./mcp-client.js";
|
|
16
18
|
import { type CodeMcpOutputDetails, formatCodeMcpOutput } from "./output.js";
|
|
17
19
|
import {
|
|
18
20
|
EXECUTE_PROMPT_GUIDELINES,
|
|
@@ -73,6 +75,8 @@ const SearchParameters = Type.Object({
|
|
|
73
75
|
),
|
|
74
76
|
});
|
|
75
77
|
|
|
78
|
+
const JevRouteParameters = Type.Object({});
|
|
79
|
+
|
|
76
80
|
const InspectParameters = Type.Object({
|
|
77
81
|
calls: Type.Array(Type.String({ minLength: 1 }), {
|
|
78
82
|
minItems: 1,
|
|
@@ -152,6 +156,86 @@ const ExecuteParameters = Type.Object({
|
|
|
152
156
|
),
|
|
153
157
|
});
|
|
154
158
|
|
|
159
|
+
const EditExecuteParameters = Type.Object({
|
|
160
|
+
oldText: Type.String({
|
|
161
|
+
minLength: 1,
|
|
162
|
+
description: "Exact text that must occur once in the previous execution code",
|
|
163
|
+
}),
|
|
164
|
+
newText: Type.String({
|
|
165
|
+
description: "Replacement text; may be empty",
|
|
166
|
+
}),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
export function registerJevRouteTool(
|
|
170
|
+
pi: ExtensionAPI,
|
|
171
|
+
getRouter: () => Pick<JevRouter, "route"> | undefined,
|
|
172
|
+
): void {
|
|
173
|
+
pi.registerTool({
|
|
174
|
+
name: "codemcp_route",
|
|
175
|
+
label: "Jev MCP Route",
|
|
176
|
+
description:
|
|
177
|
+
"Use Jev to select every configured MCP call relevant to a complete task, classify each call's workflow role, recommend parallel or dependent composition, and return exact typed SDK contracts. Use when the task may require external services or saved workflows. If no configured capability applies, returns no calls.",
|
|
178
|
+
promptSnippet: "Select and compose MCP calls for a complete task with Jev",
|
|
179
|
+
promptGuidelines: [
|
|
180
|
+
"Use codemcp_route once per distinct task when MCP capabilities may be needed; route again only if the task changes or the selected contracts cannot complete it. It reads the current request and recent conversation context automatically.",
|
|
181
|
+
"After codemcp_route returns contracts, immediately write and run the recommended minimal codemcp_execute program instead of stopping to describe the plan.",
|
|
182
|
+
"Follow codemcp_route composition guidance: gather independent calls, sequence dependent calls, and preserve a model turn only for semantic decisions or approvals.",
|
|
183
|
+
],
|
|
184
|
+
parameters: JevRouteParameters,
|
|
185
|
+
async execute(_toolCallId, _params, signal, onUpdate, ctx) {
|
|
186
|
+
const router = getRouter();
|
|
187
|
+
if (!router) throw new Error("Jev routing requires TYPESAFE_API_KEY");
|
|
188
|
+
const { task, recentContext } = currentRouteTask(ctx.sessionManager.buildContextEntries());
|
|
189
|
+
onUpdate?.({
|
|
190
|
+
content: [{ type: "text", text: "Jev is selecting and composing MCP calls..." }],
|
|
191
|
+
details: undefined,
|
|
192
|
+
});
|
|
193
|
+
try {
|
|
194
|
+
const route = await router.route(task, recentContext, signal);
|
|
195
|
+
return {
|
|
196
|
+
content: [{ type: "text", text: route.prompt }],
|
|
197
|
+
details: {
|
|
198
|
+
selected: route.selected.map((tool) => ({
|
|
199
|
+
call: tool.call,
|
|
200
|
+
relevance: tool.relevance,
|
|
201
|
+
role: tool.role,
|
|
202
|
+
})),
|
|
203
|
+
needsAnyTool: route.needsAnyTool,
|
|
204
|
+
workflowShape: route.workflowShape,
|
|
205
|
+
needsCheckpoint: route.needsCheckpoint,
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
} catch (error) {
|
|
209
|
+
const active = pi.getActiveTools();
|
|
210
|
+
if (!active.includes("codemcp_search")) {
|
|
211
|
+
pi.setActiveTools([...active, "codemcp_search"]);
|
|
212
|
+
}
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
renderCall(_args, theme) {
|
|
217
|
+
return new Text(theme.fg("toolTitle", theme.bold("Jev MCP Route")), 0, 0);
|
|
218
|
+
},
|
|
219
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
220
|
+
if (isPartial) return new Text(theme.fg("warning", "Jev is routing..."), 0, 0);
|
|
221
|
+
if (expanded) return renderExpandedJson(result.content);
|
|
222
|
+
const details = result.details as
|
|
223
|
+
| { selected?: Array<{ call?: string }>; workflowShape?: string }
|
|
224
|
+
| undefined;
|
|
225
|
+
const selected = details?.selected ?? [];
|
|
226
|
+
let text = theme.fg(
|
|
227
|
+
"success",
|
|
228
|
+
`\n${selected.length} calls · ${details?.workflowShape ?? "no workflow"}`,
|
|
229
|
+
);
|
|
230
|
+
for (const tool of selected.slice(0, 4)) {
|
|
231
|
+
if (tool.call) text += `\n${theme.fg("dim", ` ${tool.call}`)}`;
|
|
232
|
+
}
|
|
233
|
+
text += `\n${theme.fg("muted", keyHint("app.tools.expand", "routing details"))}`;
|
|
234
|
+
return new Text(text, 0, 0);
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
155
239
|
export function registerCodeMcpTools(
|
|
156
240
|
pi: ExtensionAPI,
|
|
157
241
|
lifecycle: CodeMcpLifecycle,
|
|
@@ -320,33 +404,9 @@ export function registerCodeMcpTools(
|
|
|
320
404
|
...(params.inputRef === undefined ? {} : { input_ref: params.inputRef }),
|
|
321
405
|
},
|
|
322
406
|
signal,
|
|
407
|
+
(progress) => onUpdate?.(executionProgressUpdate(progress)),
|
|
323
408
|
);
|
|
324
|
-
|
|
325
|
-
const modelValue = ok
|
|
326
|
-
? result.result
|
|
327
|
-
: {
|
|
328
|
-
failure_stage: result.failure_stage,
|
|
329
|
-
error: result.error,
|
|
330
|
-
failure: result.failure,
|
|
331
|
-
shape: result.shape,
|
|
332
|
-
result_ref: result.result_ref,
|
|
333
|
-
expires_in_seconds: result.expires_in_seconds,
|
|
334
|
-
calls_made: result.calls_made,
|
|
335
|
-
chain_calls: result.chain_calls,
|
|
336
|
-
};
|
|
337
|
-
const output = formatCodeMcpOutput(modelValue, outputLimits(lifecycle));
|
|
338
|
-
return {
|
|
339
|
-
content: [{ type: "text", text: output.text }],
|
|
340
|
-
details: {
|
|
341
|
-
...output.details,
|
|
342
|
-
ok,
|
|
343
|
-
failureStage: typeof result.failure_stage === "string" ? result.failure_stage : undefined,
|
|
344
|
-
callsMade: Number(result.calls_made ?? 0),
|
|
345
|
-
chainCalls: Number(result.chain_calls ?? 0),
|
|
346
|
-
...(isRecord(result.timings) ? { timings: result.timings } : {}),
|
|
347
|
-
preview: previewExecutionValue(ok ? result.result : result.error),
|
|
348
|
-
},
|
|
349
|
-
};
|
|
409
|
+
return formatExecutionResponse(result, lifecycle);
|
|
350
410
|
},
|
|
351
411
|
renderCall(args, theme, context) {
|
|
352
412
|
const code = args.code.trim();
|
|
@@ -379,6 +439,47 @@ export function registerCodeMcpTools(
|
|
|
379
439
|
},
|
|
380
440
|
});
|
|
381
441
|
|
|
442
|
+
pi.registerTool({
|
|
443
|
+
name: "codemcp_edit",
|
|
444
|
+
label: "MCP Edit",
|
|
445
|
+
description:
|
|
446
|
+
"Patch and rerun the most recent codemcp_execute in the current sidecar without resending its full code. oldText must match exactly once. The original inputRef is reused and the entire call graph, including upstream MCP calls, runs again.",
|
|
447
|
+
promptSnippet: "Patch and rerun the previous CodeMCP execution",
|
|
448
|
+
parameters: EditExecuteParameters,
|
|
449
|
+
async execute(toolCallId, params, signal, onUpdate) {
|
|
450
|
+
onUpdate?.({
|
|
451
|
+
content: [{ type: "text", text: "Patching and type-checking MCP chain..." }],
|
|
452
|
+
details: undefined,
|
|
453
|
+
});
|
|
454
|
+
const result = await lifecycle.request(
|
|
455
|
+
"edit_execute",
|
|
456
|
+
{
|
|
457
|
+
old_text: params.oldText,
|
|
458
|
+
new_text: params.newText,
|
|
459
|
+
trace_id: toolCallId,
|
|
460
|
+
},
|
|
461
|
+
signal,
|
|
462
|
+
(progress) => onUpdate?.(executionProgressUpdate(progress)),
|
|
463
|
+
);
|
|
464
|
+
return formatExecutionResponse(result, lifecycle);
|
|
465
|
+
},
|
|
466
|
+
renderCall(args, theme) {
|
|
467
|
+
const lines = args.oldText ? args.oldText.split("\n").length : 0;
|
|
468
|
+
const title = theme.fg("toolTitle", theme.bold("MCP Edit"));
|
|
469
|
+
const patchLabel = theme.fg(
|
|
470
|
+
"accent",
|
|
471
|
+
theme.bold(`Edited ${lines} ${lines === 1 ? "line" : "lines"}`),
|
|
472
|
+
);
|
|
473
|
+
return new Text(`${title} ${theme.fg("muted", "·")} ${patchLabel}`, 0, 0);
|
|
474
|
+
},
|
|
475
|
+
renderResult(result, state, theme) {
|
|
476
|
+
return renderExecutionResult(result, state, theme, {
|
|
477
|
+
partialText: "Applying edit, then execution...",
|
|
478
|
+
expandDescription: "full output",
|
|
479
|
+
});
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
|
|
382
483
|
pi.registerTool({
|
|
383
484
|
name: "codemcp_save_chain",
|
|
384
485
|
label: "Save MCP Chain",
|
|
@@ -552,6 +653,46 @@ export function registerCodeMcpTools(
|
|
|
552
653
|
});
|
|
553
654
|
}
|
|
554
655
|
|
|
656
|
+
function executionProgressUpdate(progress: SidecarProgress) {
|
|
657
|
+
const currentCall = progress.message === "executing" ? undefined : progress.message;
|
|
658
|
+
return {
|
|
659
|
+
content: [{ type: "text" as const, text: progress.message ?? "Executing MCP calls..." }],
|
|
660
|
+
details: {
|
|
661
|
+
callsMade: progress.progress,
|
|
662
|
+
...(currentCall === undefined ? {} : { currentCall }),
|
|
663
|
+
},
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function formatExecutionResponse(result: Record<string, unknown>, lifecycle: CodeMcpLifecycle) {
|
|
668
|
+
const ok = result.ok === true;
|
|
669
|
+
const modelValue = ok
|
|
670
|
+
? result.result
|
|
671
|
+
: {
|
|
672
|
+
failure_stage: result.failure_stage,
|
|
673
|
+
error: result.error,
|
|
674
|
+
failure: result.failure,
|
|
675
|
+
shape: result.shape,
|
|
676
|
+
result_ref: result.result_ref,
|
|
677
|
+
expires_in_seconds: result.expires_in_seconds,
|
|
678
|
+
calls_made: result.calls_made,
|
|
679
|
+
chain_calls: result.chain_calls,
|
|
680
|
+
};
|
|
681
|
+
const output = formatCodeMcpOutput(modelValue, outputLimits(lifecycle));
|
|
682
|
+
return {
|
|
683
|
+
content: [{ type: "text" as const, text: output.text }],
|
|
684
|
+
details: {
|
|
685
|
+
...output.details,
|
|
686
|
+
ok,
|
|
687
|
+
failureStage: typeof result.failure_stage === "string" ? result.failure_stage : undefined,
|
|
688
|
+
callsMade: Number(result.calls_made ?? 0),
|
|
689
|
+
chainCalls: Number(result.chain_calls ?? 0),
|
|
690
|
+
...(isRecord(result.timings) ? { timings: result.timings } : {}),
|
|
691
|
+
preview: previewExecutionValue(ok ? result.result : result.error),
|
|
692
|
+
},
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
555
696
|
function compactChainView(view: SavedChainView) {
|
|
556
697
|
return {
|
|
557
698
|
name: view.chain.name,
|
|
@@ -582,6 +723,40 @@ function outputLimits(lifecycle: CodeMcpLifecycle): { maxBytes: number } {
|
|
|
582
723
|
return { maxBytes: settings.outputLimitKiB * 1024 };
|
|
583
724
|
}
|
|
584
725
|
|
|
726
|
+
function currentRouteTask(entries: readonly unknown[]): {
|
|
727
|
+
task: string;
|
|
728
|
+
recentContext: string;
|
|
729
|
+
} {
|
|
730
|
+
const messages: Array<{ role: "user" | "assistant"; text: string }> = [];
|
|
731
|
+
for (const entry of entries) {
|
|
732
|
+
if (!isRecord(entry) || !isRecord(entry.message)) continue;
|
|
733
|
+
const role = entry.message.role;
|
|
734
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
735
|
+
const text = messageText(entry.message.content).trim();
|
|
736
|
+
if (text) messages.push({ role, text });
|
|
737
|
+
}
|
|
738
|
+
let currentIndex = messages.length - 1;
|
|
739
|
+
while (currentIndex >= 0 && messages[currentIndex]?.role !== "user") currentIndex -= 1;
|
|
740
|
+
const current = messages[currentIndex];
|
|
741
|
+
if (!current) throw new Error("Jev routing requires a text user request");
|
|
742
|
+
const recentContext = messages
|
|
743
|
+
.slice(Math.max(0, currentIndex - 3), currentIndex)
|
|
744
|
+
.map((message) => `${message.role === "user" ? "User" : "Assistant"}: ${message.text}`)
|
|
745
|
+
.join("\n\n")
|
|
746
|
+
.slice(-6_000);
|
|
747
|
+
return { task: current.text, recentContext };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function messageText(content: unknown): string {
|
|
751
|
+
if (typeof content === "string") return content;
|
|
752
|
+
if (!Array.isArray(content)) return "";
|
|
753
|
+
return content
|
|
754
|
+
.flatMap((item) =>
|
|
755
|
+
isRecord(item) && item.type === "text" && typeof item.text === "string" ? [item.text] : [],
|
|
756
|
+
)
|
|
757
|
+
.join("\n");
|
|
758
|
+
}
|
|
759
|
+
|
|
585
760
|
function truncate(value: string, maxLength: number): string {
|
|
586
761
|
return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`;
|
|
587
762
|
}
|