Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/coding-agent/.changes/dead-kernel-memo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- A Python kernel that dies after a successful startup is restarted on the next use instead of every call being handed the dead kernel forever, and skill-MCP tools advertise their real input schemas again under mcp>=2 (the SDK renamed the field to input_schema).
4 changes: 4 additions & 0 deletions packages/coding-agent/src/core/kernel/repl-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1499,4 +1499,8 @@ export class ReplKernelManager {
get isRunning(): boolean {
return this.state === "running";
}

get isDefunct(): boolean {
return this.state === "shutdown";
}
}
2 changes: 2 additions & 0 deletions packages/coding-agent/src/core/kernel/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ export interface KernelShutdownOptions {
export interface KernelClient {
readonly ownerSessionId: string | undefined;
readonly isRunning: boolean;
/** Terminal: the kernel died or was torn down; only a fresh manager can serve again. */
readonly isDefunct: boolean;
start(options?: KernelStartOptions): Promise<void>;
execute(code: string, opts?: ExecuteOptions): Promise<ExecuteResult>;
shutdown(opts?: KernelShutdownOptions): Promise<boolean>;
Expand Down
6 changes: 6 additions & 0 deletions packages/coding-agent/src/core/tools/ipython.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,12 @@ export class IpythonKernelProvisioner {
if (signal?.aborted) {
return Promise.reject(createAbortError());
}
// A kernel that died for good must not be handed out again; a manager
// mid-protocol-repair (idle/starting) recovers itself and keeps the memo.
if (this.startedManager?.isDefunct) {
this.managerPromise = undefined;
this.startedManager = undefined;
}
Comment thread
cursor[bot] marked this conversation as resolved.
let cleanupProgressListener: (() => void) | undefined;
if (onProgress && !this.startedManager) {
this.startupListeners.add(onProgress);
Expand Down
40 changes: 40 additions & 0 deletions packages/coding-agent/test/ipython-provisioner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,46 @@ describe("IpythonKernelProvisioner", () => {
expect(provisioner.manager).toBe(manager);
});

it("drops a dead kernel memo so ensure() restarts instead of reusing it", async () => {
const { python, countRuns } = writeFakePython();
const provisioner = new IpythonKernelProvisioner(tempDir, { python });
const dead = { isRunning: false, isDefunct: true } as unknown as KernelClient;
Object.assign(
provisioner as unknown as {
managerPromise: Promise<KernelClient>;
startedManager: KernelClient;
},
{
managerPromise: Promise.resolve(dead),
startedManager: dead,
},
);

await expect(provisioner.ensure()).rejects.toThrow(/Kernel exited before ready/);
expect(countRuns()).toBe(1);
});

it("keeps the memo for a kernel that is repairing itself, not defunct", async () => {
const { countRuns } = writeFakePython();
const provisioner = new IpythonKernelProvisioner(tempDir, {});
const repairing = { isRunning: false, isDefunct: false } as unknown as KernelClient;
Object.assign(
provisioner as unknown as {
managerPromise: Promise<KernelClient>;
startedManager: KernelClient;
},
{
managerPromise: Promise.resolve(repairing),
startedManager: repairing,
},
);

// A protocol repair parks the SAME manager in idle/starting while it
// respawns; a second provisioner kernel would split the snapshot dir.
await expect(provisioner.ensure()).resolves.toBe(repairing);
expect(countRuns()).toBe(0);
});

it("removes startup progress listeners when an ensure caller is aborted", async () => {
const provisioner = new IpythonKernelProvisioner(tempDir, {});
Object.assign(
Expand Down
14 changes: 9 additions & 5 deletions prime-agent-runtime/src/rlm/mcp_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,14 +260,18 @@ async def _ensure_tools(self) -> None:
async with AsyncExitStack() as stack:
session = await self._open_session(stack)
resp = await session.list_tools()
self._tools = {
t.name: {
tools: dict[str, Any] = {}
for t in resp.tools:
# mcp>=2 exposes the pydantic field input_schema; inputSchema is the wire alias.
schema = getattr(t, "input_schema", None)
if schema is None:
schema = getattr(t, "inputSchema", None)
tools[t.name] = {
"name": t.name,
"description": getattr(t, "description", "") or "",
"inputSchema": getattr(t, "inputSchema", None) or {},
"inputSchema": schema if isinstance(schema, dict) else {},
}
for t in resp.tools
}
self._tools = tools

async def call_tool(self, tool: str, arguments: dict[str, Any] | None = None) -> Any:
"""Call ``tool`` on the server and return its parsed result.
Expand Down
23 changes: 23 additions & 0 deletions prime-agent-runtime/test/test_mcp_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,29 @@ def test_auto_bound_tool_calls_session(self):
self.assertEqual(out, {"issues": [1, 2]})
self.assertEqual(session.calls, [("list_issues", {"team": "Eng"})])

def test_snake_case_input_schema_surfaces(self):
# mcp>=2 Tool objects expose input_schema (pydantic field name), not inputSchema.
Tool = type("Tool", (), {})
tool = Tool()
tool.name = "list_issues"
tool.description = "List issues"
tool.input_schema = {"type": "object", "properties": {"team": {"type": "string"}}}
session = _FakeSession(tools=[], result=None)

async def list_tools():
resp = type("Resp", (), {})()
resp.tools = [tool]
return resp

session.list_tools = list_tools
self._write_auth(
{"type": "oauth", "access": "t", "refresh": "r", "expires": (time.time() + 3600) * 1000}
)
with self._patch_session(session):
integration = _Integration()
tools = _run(integration.list_tools())
self.assertEqual(tools[0]["inputSchema"], tool.input_schema)

def test_unknown_tool_raises_with_available_list(self):
session = _FakeSession(tools=[("list_issues", "", {})], result=None)
self._write_auth(
Expand Down
Loading