opencode-cursor-provider 0.2.0 → 0.3.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 +7 -4
- package/dist/bridge/bridge.d.ts +3 -0
- package/dist/bridge/bridge.js +6 -0
- package/dist/bridge/conversation.d.ts +6 -0
- package/dist/bridge/conversation.js +30 -0
- package/dist/bridge/response-journal.js +1 -1
- package/dist/bridge/tool-bridge.d.ts +26 -0
- package/dist/bridge/tool-bridge.js +81 -0
- package/dist/bridge/translate.d.ts +6 -1
- package/dist/bridge/turn.d.ts +8 -2
- package/dist/bridge/turn.js +210 -78
- package/dist/catalog/catalog.js +1 -1
- package/dist/model/language-model.js +49 -3
- package/dist/plugin.d.ts +5 -0
- package/dist/plugin.js +15 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -47,8 +47,9 @@ The plugin gets the model list and the model variants from Cursor. The fallback
|
|
|
47
47
|
## How the plugin works
|
|
48
48
|
|
|
49
49
|
- Cursor runs its own tools and changes the workspace.
|
|
50
|
-
- OpenCode tools stay off for Cursor models.
|
|
51
50
|
- OpenCode shows Cursor tools as provider-executed tool calls.
|
|
51
|
+
- The plugin exposes OpenCode tools to Cursor with an `opencode__` prefix for each run.
|
|
52
|
+
- OpenCode executes bridged tools through its normal permissions, hooks, and MCP connections.
|
|
52
53
|
- The plugin keeps one Cursor agent for each OpenCode session.
|
|
53
54
|
- The plugin starts a new Cursor agent after a model, directory, mode, conversation, or Cursor agent option change.
|
|
54
55
|
- OpenCode cancellation stops the active Cursor run.
|
|
@@ -69,7 +70,7 @@ providerOptions: {
|
|
|
69
70
|
}
|
|
70
71
|
```
|
|
71
72
|
|
|
72
|
-
The `tools` and `disallowedTools` options apply only to Cursor tools.
|
|
73
|
+
The `tools` and `disallowedTools` options apply only to Cursor tools. OpenCode controls its bridged tools separately.
|
|
73
74
|
|
|
74
75
|
Because Cursor settings can load MCP servers, select only settings layers that you trust.
|
|
75
76
|
|
|
@@ -78,10 +79,12 @@ Because Cursor settings can load MCP servers, select only settings layers that y
|
|
|
78
79
|
- The plugin supports text and local image input. It supports text output.
|
|
79
80
|
- The plugin rejects image URLs and non-image file input. It does not discard the input.
|
|
80
81
|
- The plugin rejects structured output requests.
|
|
81
|
-
- The plugin rejects
|
|
82
|
+
- The plugin rejects explicit tool choice.
|
|
83
|
+
- Tool bridging requires an OpenCode session. One-shot model calls cannot use OpenCode tools.
|
|
82
84
|
- OpenCode sampling settings do not change Cursor model parameters. The plugin reports those settings as unsupported.
|
|
83
85
|
- Cursor controls tool access and tool approval for its tools.
|
|
84
|
-
-
|
|
86
|
+
- OpenCode controls permission checks for bridged OpenCode tools.
|
|
87
|
+
- The plugin does not accept user-defined Cursor custom tools, extra workspace roots, or a replacement system prompt.
|
|
85
88
|
|
|
86
89
|
## Credentials
|
|
87
90
|
|
package/dist/bridge/bridge.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CursorLink } from "../auth/link.ts";
|
|
2
2
|
import type { CursorModelDescriptor } from "../catalog/catalog.ts";
|
|
3
|
+
import type { OpencodeSessionID } from "../ids.ts";
|
|
3
4
|
import { type BindingStore, type TurnScope } from "./binding.ts";
|
|
4
5
|
import { type TurnRequest } from "./turn.ts";
|
|
5
6
|
import type { TurnEvent } from "./translate.ts";
|
|
@@ -7,6 +8,8 @@ export type { TurnRequest } from "./turn.ts";
|
|
|
7
8
|
export interface SessionAgentBridge {
|
|
8
9
|
annotate(system: string[], scope: TurnScope): string[];
|
|
9
10
|
turn(request: TurnRequest): AsyncIterable<TurnEvent>;
|
|
11
|
+
cancel(sessionID: OpencodeSessionID, reason: string): Promise<void>;
|
|
12
|
+
dispose(): Promise<void>;
|
|
10
13
|
}
|
|
11
14
|
export declare function createSessionAgentBridge(input: {
|
|
12
15
|
link: CursorLink;
|
package/dist/bridge/bridge.js
CHANGED
|
@@ -61,4 +61,10 @@ export declare function resumeTurn(conversation: Conversation, checkpoint: Conve
|
|
|
61
61
|
role: "user";
|
|
62
62
|
}> | undefined;
|
|
63
63
|
export declare function extendsCheckpoint(conversation: Conversation, checkpoint: ConversationCheckpoint): boolean;
|
|
64
|
+
export declare function toolResultsAfter(conversation: Conversation, checkpoint: ConversationCheckpoint, calls: readonly {
|
|
65
|
+
readonly id: string;
|
|
66
|
+
readonly name: string;
|
|
67
|
+
}[]): readonly Extract<ToolPart, {
|
|
68
|
+
type: "tool-result";
|
|
69
|
+
}>[] | undefined;
|
|
64
70
|
export declare function canonicalJson(value: unknown): string;
|
|
@@ -28,6 +28,36 @@ export function resumeTurn(conversation, checkpoint) {
|
|
|
28
28
|
export function extendsCheckpoint(conversation, checkpoint) {
|
|
29
29
|
return resumeTurn(conversation, checkpoint) !== undefined;
|
|
30
30
|
}
|
|
31
|
+
export function toolResultsAfter(conversation, checkpoint, calls) {
|
|
32
|
+
if (digest(conversation.system) !== checkpoint.system)
|
|
33
|
+
return undefined;
|
|
34
|
+
if (conversation.turns.length !== checkpoint.turns.length + 1)
|
|
35
|
+
return undefined;
|
|
36
|
+
for (let index = 0; index < checkpoint.turns.length; index += 1) {
|
|
37
|
+
const turn = conversation.turns[index];
|
|
38
|
+
if (turn === undefined || digest(turn) !== checkpoint.turns[index])
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const suffix = conversation.turns.at(-1);
|
|
42
|
+
if (suffix?.role !== "tool")
|
|
43
|
+
return undefined;
|
|
44
|
+
if (suffix.parts.length !== calls.length)
|
|
45
|
+
return undefined;
|
|
46
|
+
const expected = new Map(calls.map((call) => [call.id, call.name]));
|
|
47
|
+
if (expected.size !== calls.length)
|
|
48
|
+
return undefined;
|
|
49
|
+
const found = new Set();
|
|
50
|
+
const results = [];
|
|
51
|
+
for (const part of suffix.parts) {
|
|
52
|
+
if (part.type !== "tool-result")
|
|
53
|
+
return undefined;
|
|
54
|
+
if (expected.get(part.id) !== part.name || found.has(part.id))
|
|
55
|
+
return undefined;
|
|
56
|
+
found.add(part.id);
|
|
57
|
+
results.push(part);
|
|
58
|
+
}
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
31
61
|
export function canonicalJson(value) {
|
|
32
62
|
return JSON.stringify(ordered(value)) ?? "null";
|
|
33
63
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SDKCustomTool, SDKJsonValue } from "@cursor/sdk";
|
|
2
|
+
import type { UserPart } from "./conversation.ts";
|
|
3
|
+
export interface OpenCodeToolRequest {
|
|
4
|
+
readonly id: string;
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly input: Record<string, SDKJsonValue>;
|
|
7
|
+
}
|
|
8
|
+
export interface OpenCodeToolDefinition {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly description?: string;
|
|
11
|
+
readonly inputSchema: Record<string, SDKJsonValue>;
|
|
12
|
+
}
|
|
13
|
+
export interface OpenCodeToolResult {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly output: readonly UserPart[];
|
|
16
|
+
readonly isError: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface OpenCodeToolBridge {
|
|
19
|
+
readonly customTools: Record<string, SDKCustomTool>;
|
|
20
|
+
waitForCalls(): Promise<void>;
|
|
21
|
+
takeCalls(): readonly OpenCodeToolRequest[];
|
|
22
|
+
resolve(results: readonly OpenCodeToolResult[]): number;
|
|
23
|
+
isCursorCall(id: string): boolean;
|
|
24
|
+
cancel(reason: string): void;
|
|
25
|
+
}
|
|
26
|
+
export declare function createOpenCodeToolBridge(tools: readonly OpenCodeToolDefinition[], bridgeID?: string): OpenCodeToolBridge;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
const TOOL_PREFIX = "opencode__";
|
|
3
|
+
export function createOpenCodeToolBridge(tools, bridgeID = randomUUID()) {
|
|
4
|
+
const queued = [];
|
|
5
|
+
const pending = new Map();
|
|
6
|
+
const cursorCallIDs = new Set();
|
|
7
|
+
let sequence = 0;
|
|
8
|
+
let waiter;
|
|
9
|
+
let wake;
|
|
10
|
+
const entries = tools
|
|
11
|
+
.map((tool) => [
|
|
12
|
+
`${TOOL_PREFIX}${tool.name}`,
|
|
13
|
+
{
|
|
14
|
+
...(tool.description === undefined ? {} : { description: tool.description }),
|
|
15
|
+
inputSchema: tool.inputSchema,
|
|
16
|
+
execute(args, context) {
|
|
17
|
+
sequence += 1;
|
|
18
|
+
if (context.toolCallId !== undefined)
|
|
19
|
+
cursorCallIDs.add(context.toolCallId);
|
|
20
|
+
const id = `${bridgeID}-${sequence}`;
|
|
21
|
+
const request = { id, name: tool.name, input: args };
|
|
22
|
+
queued.push(request);
|
|
23
|
+
const waiting = new Promise((resolve, reject) => {
|
|
24
|
+
pending.set(id, { request, resolve, reject });
|
|
25
|
+
});
|
|
26
|
+
wake?.();
|
|
27
|
+
wake = undefined;
|
|
28
|
+
waiter = undefined;
|
|
29
|
+
return waiting;
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
]);
|
|
33
|
+
return {
|
|
34
|
+
customTools: Object.fromEntries(entries),
|
|
35
|
+
waitForCalls() {
|
|
36
|
+
if (queued.length > 0)
|
|
37
|
+
return Promise.resolve();
|
|
38
|
+
if (waiter !== undefined)
|
|
39
|
+
return waiter;
|
|
40
|
+
waiter = new Promise((resolve) => {
|
|
41
|
+
wake = resolve;
|
|
42
|
+
});
|
|
43
|
+
return waiter;
|
|
44
|
+
},
|
|
45
|
+
takeCalls() {
|
|
46
|
+
return queued.splice(0);
|
|
47
|
+
},
|
|
48
|
+
resolve(results) {
|
|
49
|
+
let resolved = 0;
|
|
50
|
+
for (const result of results) {
|
|
51
|
+
const call = pending.get(result.id);
|
|
52
|
+
if (call === undefined)
|
|
53
|
+
continue;
|
|
54
|
+
pending.delete(result.id);
|
|
55
|
+
resolved += 1;
|
|
56
|
+
call.resolve({
|
|
57
|
+
content: result.output.map((part) => {
|
|
58
|
+
if (part.type === "text")
|
|
59
|
+
return { type: "text", text: part.text };
|
|
60
|
+
return { type: "image", data: part.image.data, mimeType: part.image.mimeType };
|
|
61
|
+
}),
|
|
62
|
+
...(result.isError ? { isError: true } : {}),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return resolved;
|
|
66
|
+
},
|
|
67
|
+
isCursorCall(id) {
|
|
68
|
+
return cursorCallIDs.has(id);
|
|
69
|
+
},
|
|
70
|
+
cancel(reason) {
|
|
71
|
+
const error = new Error(reason);
|
|
72
|
+
for (const call of pending.values())
|
|
73
|
+
call.reject(error);
|
|
74
|
+
pending.clear();
|
|
75
|
+
queued.splice(0);
|
|
76
|
+
wake?.();
|
|
77
|
+
wake = undefined;
|
|
78
|
+
waiter = undefined;
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -19,6 +19,11 @@ export type TurnEvent = {
|
|
|
19
19
|
readonly id: string;
|
|
20
20
|
readonly name: string;
|
|
21
21
|
readonly input: JsonValue;
|
|
22
|
+
} | {
|
|
23
|
+
readonly type: "tool-request";
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly name: string;
|
|
26
|
+
readonly input: JsonValue;
|
|
22
27
|
} | {
|
|
23
28
|
readonly type: "tool-result";
|
|
24
29
|
readonly id: string;
|
|
@@ -35,7 +40,7 @@ export type TurnEvent = {
|
|
|
35
40
|
readonly total: number;
|
|
36
41
|
} | {
|
|
37
42
|
readonly type: "done";
|
|
38
|
-
readonly reason: "stop" | "length" | "aborted";
|
|
43
|
+
readonly reason: "stop" | "length" | "aborted" | "tool-calls";
|
|
39
44
|
readonly metadata?: {
|
|
40
45
|
readonly runId: string;
|
|
41
46
|
readonly requestId?: string;
|
package/dist/bridge/turn.d.ts
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
import { type AgentModeOption, type ModelParameterValue } from "@cursor/sdk";
|
|
2
2
|
import type { CursorLink } from "../auth/link.ts";
|
|
3
3
|
import { type CursorModelDescriptor } from "../catalog/catalog.ts";
|
|
4
|
-
import { type CatalogModelID } from "../ids.ts";
|
|
4
|
+
import { type CatalogModelID, type OpencodeSessionID } from "../ids.ts";
|
|
5
5
|
import type { CursorAgentOptions } from "../model/provider-options.ts";
|
|
6
6
|
import { openCursorAgent } from "./agent.ts";
|
|
7
7
|
import { type BindingStore, type TurnScope } from "./binding.ts";
|
|
8
8
|
import { type Conversation } from "./conversation.ts";
|
|
9
9
|
import type { KeyedLock } from "./lock.ts";
|
|
10
|
+
import { type OpenCodeToolDefinition } from "./tool-bridge.ts";
|
|
10
11
|
import { type TurnEvent } from "./translate.ts";
|
|
11
12
|
export interface TurnRequest {
|
|
12
13
|
readonly modelID: CatalogModelID;
|
|
13
14
|
readonly scope: TurnScope | undefined;
|
|
14
15
|
readonly conversation: Conversation;
|
|
16
|
+
readonly tools?: readonly OpenCodeToolDefinition[];
|
|
15
17
|
readonly params?: readonly ModelParameterValue[];
|
|
16
18
|
readonly mode?: AgentModeOption;
|
|
17
19
|
readonly agentOptions?: CursorAgentOptions;
|
|
@@ -26,4 +28,8 @@ export interface TurnRunnerContext {
|
|
|
26
28
|
readonly lock: KeyedLock;
|
|
27
29
|
readonly openAgent?: typeof openCursorAgent;
|
|
28
30
|
}
|
|
29
|
-
export declare function createTurnRunner(ctx: TurnRunnerContext): (request: TurnRequest) =>
|
|
31
|
+
export declare function createTurnRunner(ctx: TurnRunnerContext): ((request: TurnRequest) => AsyncGenerator<TurnEvent, any, any>) & {
|
|
32
|
+
cancel(sessionID: OpencodeSessionID, reason: string): Promise<void>;
|
|
33
|
+
dispose(): Promise<void>;
|
|
34
|
+
};
|
|
35
|
+
export type TurnRunner = ReturnType<typeof createTurnRunner>;
|
package/dist/bridge/turn.js
CHANGED
|
@@ -3,16 +3,81 @@ import { resolveWireId } from "../catalog/catalog.js";
|
|
|
3
3
|
import { nowMs } from "../ids.js";
|
|
4
4
|
import { isAgentLost, openCursorAgent } from "./agent.js";
|
|
5
5
|
import { route } from "./binding.js";
|
|
6
|
-
import { checkpointOf, cursorMessage, resumeTurn, } from "./conversation.js";
|
|
6
|
+
import { canonicalJson, checkpointOf, cursorMessage, resumeTurn, toolResultsAfter, } from "./conversation.js";
|
|
7
7
|
import { createResponseJournal } from "./response-journal.js";
|
|
8
|
+
import { createOpenCodeToolBridge, } from "./tool-bridge.js";
|
|
8
9
|
import { createMessageTranslator } from "./translate.js";
|
|
9
10
|
export function createTurnRunner(ctx) {
|
|
10
|
-
|
|
11
|
+
const liveRuns = new Map();
|
|
12
|
+
return Object.assign((request) => runTurn(ctx, request, liveRuns), {
|
|
13
|
+
async cancel(sessionID, reason) {
|
|
14
|
+
const release = await ctx.lock.acquire(sessionID);
|
|
15
|
+
try {
|
|
16
|
+
const live = liveRuns.get(sessionID);
|
|
17
|
+
if (live === undefined)
|
|
18
|
+
return;
|
|
19
|
+
liveRuns.delete(sessionID);
|
|
20
|
+
await closeLiveRun(live, reason);
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
release();
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
async dispose() {
|
|
27
|
+
await Promise.all([...liveRuns.values()].map((live) => closeLiveRun(live, "Cursor provider stopped")));
|
|
28
|
+
liveRuns.clear();
|
|
29
|
+
},
|
|
30
|
+
});
|
|
11
31
|
}
|
|
12
|
-
async function* runTurn(ctx, request) {
|
|
32
|
+
async function* runTurn(ctx, request, liveRuns) {
|
|
13
33
|
const lockKey = request.scope?.sessionID ?? "one-shot";
|
|
14
34
|
const release = await ctx.lock.acquire(lockKey);
|
|
15
35
|
try {
|
|
36
|
+
const scope = request.scope;
|
|
37
|
+
if (scope === undefined && (request.tools?.length ?? 0) > 0) {
|
|
38
|
+
yield { type: "failed", error: { kind: "unsupported-request", reason: "tools-requested" } };
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const suspended = scope === undefined ? undefined : liveRuns.get(scope.sessionID);
|
|
42
|
+
if (scope !== undefined && suspended !== undefined) {
|
|
43
|
+
if (suspended.identity !== liveIdentity(request)) {
|
|
44
|
+
await closeLiveRun(suspended, "The OpenCode request changed while a tool call was pending");
|
|
45
|
+
liveRuns.delete(scope.sessionID);
|
|
46
|
+
ctx.bindings.drop(scope.sessionID);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
const continuation = suspended.continuation;
|
|
50
|
+
const parts = continuation === undefined
|
|
51
|
+
? undefined
|
|
52
|
+
: toolResultsAfter(request.conversation, continuation.checkpoint, continuation.calls);
|
|
53
|
+
const resolved = parts === undefined
|
|
54
|
+
? 0
|
|
55
|
+
: suspended.toolBridge?.resolve(parts.map((part) => ({ id: part.id, output: part.output, isError: part.isError }))) ?? 0;
|
|
56
|
+
if (resolved !== continuation?.calls.length) {
|
|
57
|
+
await closeLiveRun(suspended, "OpenCode did not return the pending tool result");
|
|
58
|
+
liveRuns.delete(scope.sessionID);
|
|
59
|
+
ctx.bindings.drop(scope.sessionID);
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
let outcome;
|
|
63
|
+
try {
|
|
64
|
+
outcome = yield* drainLiveRun(ctx, request, suspended);
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
liveRuns.delete(scope.sessionID);
|
|
68
|
+
ctx.bindings.drop(scope.sessionID);
|
|
69
|
+
await closeLiveRun(suspended, "The Cursor run failed");
|
|
70
|
+
yield failedFromCaught(error, request.signal, ctx.link.reject);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (outcome === "suspended")
|
|
74
|
+
return;
|
|
75
|
+
liveRuns.delete(scope.sessionID);
|
|
76
|
+
await suspended.session.dispose();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
16
81
|
const apiKey = await ctx.link.resolve();
|
|
17
82
|
if (apiKey === undefined) {
|
|
18
83
|
yield { type: "failed", error: { kind: "not-linked" } };
|
|
@@ -49,7 +114,13 @@ async function* runTurn(ctx, request) {
|
|
|
49
114
|
yield { type: "failed", error: { kind: "cancelled" } };
|
|
50
115
|
return;
|
|
51
116
|
}
|
|
52
|
-
const
|
|
117
|
+
const toolBridge = request.scope === undefined ? undefined : createOpenCodeToolBridge(request.tools ?? []);
|
|
118
|
+
const customTools = toolBridge === undefined ? {} : toolBridge.customTools;
|
|
119
|
+
const sendOptions = {
|
|
120
|
+
...(request.mode === undefined ? {} : { mode: request.mode }),
|
|
121
|
+
...(Object.keys(customTools).length === 0 ? {} : { local: { customTools } }),
|
|
122
|
+
};
|
|
123
|
+
const run = await session.send(plan.prompt, Object.keys(sendOptions).length === 0 ? undefined : sendOptions);
|
|
53
124
|
emitted = true;
|
|
54
125
|
yield {
|
|
55
126
|
type: "response-metadata",
|
|
@@ -57,82 +128,24 @@ async function* runTurn(ctx, request) {
|
|
|
57
128
|
...(run.createdAt === undefined ? {} : { timestamp: run.createdAt }),
|
|
58
129
|
modelId: run.model?.id ?? plan.wireID,
|
|
59
130
|
};
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
131
|
+
const live = {
|
|
132
|
+
identity: liveIdentity(request),
|
|
133
|
+
session,
|
|
134
|
+
run,
|
|
135
|
+
messages: run.stream()[Symbol.asyncIterator](),
|
|
136
|
+
toolBridge,
|
|
137
|
+
translate: createMessageTranslator(),
|
|
138
|
+
plan,
|
|
139
|
+
nextMessage: undefined,
|
|
140
|
+
continuation: undefined,
|
|
141
|
+
sawUsage: false,
|
|
65
142
|
};
|
|
66
|
-
|
|
67
|
-
if (request.
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const response = createResponseJournal();
|
|
71
|
-
const translate = createMessageTranslator();
|
|
72
|
-
try {
|
|
73
|
-
for await (const message of run.stream()) {
|
|
74
|
-
if (request.signal?.aborted) {
|
|
75
|
-
yield { type: "failed", error: { kind: "cancelled" } };
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
if (request.includeRawChunks === true)
|
|
79
|
-
yield { type: "raw", value: message };
|
|
80
|
-
for (const candidate of translate(message)) {
|
|
81
|
-
const event = response.accept(candidate);
|
|
82
|
-
if (event === undefined)
|
|
83
|
-
continue;
|
|
84
|
-
if (event.type === "usage")
|
|
85
|
-
sawUsage = true;
|
|
86
|
-
yield event;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
if (request.signal?.aborted) {
|
|
90
|
-
yield { type: "failed", error: { kind: "cancelled" } };
|
|
91
|
-
return;
|
|
92
|
-
}
|
|
93
|
-
const result = await run.wait();
|
|
94
|
-
if (result.status === "cancelled") {
|
|
95
|
-
yield { type: "failed", error: { kind: "cancelled" } };
|
|
96
|
-
return;
|
|
97
|
-
}
|
|
98
|
-
if (!sawUsage && result.usage !== undefined)
|
|
99
|
-
yield usageEvent(result.usage);
|
|
100
|
-
if (result.status === "error") {
|
|
101
|
-
yield {
|
|
102
|
-
type: "failed",
|
|
103
|
-
error: { kind: "agent-run-failed", detail: result.error?.message ?? result.id },
|
|
104
|
-
};
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
if (request.scope && plan.kind !== "ONE_SHOT") {
|
|
108
|
-
ctx.bindings.put({
|
|
109
|
-
sessionID: request.scope.sessionID,
|
|
110
|
-
agentID: session.id,
|
|
111
|
-
modelID: request.modelID,
|
|
112
|
-
cwd: request.scope.cwd,
|
|
113
|
-
checkpoint: checkpointOf(withResponse(request.conversation, response.parts())),
|
|
114
|
-
params: request.params,
|
|
115
|
-
mode: request.mode,
|
|
116
|
-
agentOptions: request.agentOptions,
|
|
117
|
-
lastUsedAt: nowMs(ctx.clock),
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
yield {
|
|
121
|
-
type: "done",
|
|
122
|
-
reason: "stop",
|
|
123
|
-
metadata: {
|
|
124
|
-
runId: result.id,
|
|
125
|
-
...(result.requestId === undefined ? {} : { requestId: result.requestId }),
|
|
126
|
-
...(result.durationMs === undefined ? {} : { durationMs: result.durationMs }),
|
|
127
|
-
...(result.model === undefined ? {} : { modelId: result.model.id }),
|
|
128
|
-
},
|
|
129
|
-
};
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
finally {
|
|
133
|
-
request.signal?.removeEventListener("abort", cancel);
|
|
134
|
-
await cancelPromise;
|
|
143
|
+
const outcome = yield* drainLiveRun(ctx, request, live);
|
|
144
|
+
if (outcome === "suspended" && request.scope !== undefined) {
|
|
145
|
+
liveRuns.set(request.scope.sessionID, live);
|
|
146
|
+
session = undefined;
|
|
135
147
|
}
|
|
148
|
+
return;
|
|
136
149
|
}
|
|
137
150
|
catch (error) {
|
|
138
151
|
if (retryLost && !emitted && plan.kind === "RESUME" && request.scope && isAgentLost(error)) {
|
|
@@ -158,6 +171,125 @@ async function* runTurn(ctx, request) {
|
|
|
158
171
|
release();
|
|
159
172
|
}
|
|
160
173
|
}
|
|
174
|
+
function liveIdentity(request) {
|
|
175
|
+
return canonicalJson({
|
|
176
|
+
modelID: request.modelID,
|
|
177
|
+
cwd: request.scope?.cwd,
|
|
178
|
+
params: request.params,
|
|
179
|
+
mode: request.mode,
|
|
180
|
+
agentOptions: request.agentOptions,
|
|
181
|
+
tools: request.tools,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
async function* drainLiveRun(ctx, request, live) {
|
|
185
|
+
let cancelPromise;
|
|
186
|
+
const cancel = () => {
|
|
187
|
+
live.toolBridge?.cancel("The OpenCode request was cancelled");
|
|
188
|
+
if (cancelPromise !== undefined || !live.run.supports("cancel"))
|
|
189
|
+
return;
|
|
190
|
+
cancelPromise = live.run.cancel().catch(() => { });
|
|
191
|
+
};
|
|
192
|
+
request.signal?.addEventListener("abort", cancel);
|
|
193
|
+
if (request.signal?.aborted)
|
|
194
|
+
cancel();
|
|
195
|
+
const response = createResponseJournal();
|
|
196
|
+
try {
|
|
197
|
+
while (true) {
|
|
198
|
+
if (request.signal?.aborted) {
|
|
199
|
+
yield { type: "failed", error: { kind: "cancelled" } };
|
|
200
|
+
return "finished";
|
|
201
|
+
}
|
|
202
|
+
const nextMessage = live.nextMessage ?? live.messages.next();
|
|
203
|
+
live.nextMessage = nextMessage;
|
|
204
|
+
const next = live.toolBridge === undefined
|
|
205
|
+
? { type: "message", value: await nextMessage }
|
|
206
|
+
: await Promise.race([
|
|
207
|
+
nextMessage.then((value) => ({ type: "message", value })),
|
|
208
|
+
live.toolBridge.waitForCalls().then(() => ({ type: "tools" })),
|
|
209
|
+
]);
|
|
210
|
+
if (next.type === "tools") {
|
|
211
|
+
const calls = live.toolBridge?.takeCalls() ?? [];
|
|
212
|
+
if (calls.length === 0)
|
|
213
|
+
continue;
|
|
214
|
+
for (const call of calls) {
|
|
215
|
+
const event = { type: "tool-request", ...call };
|
|
216
|
+
response.accept(event);
|
|
217
|
+
yield event;
|
|
218
|
+
}
|
|
219
|
+
live.continuation = {
|
|
220
|
+
checkpoint: checkpointOf(withResponse(request.conversation, response.parts())),
|
|
221
|
+
calls: calls.map((call) => ({ id: call.id, name: call.name })),
|
|
222
|
+
};
|
|
223
|
+
yield { type: "done", reason: "tool-calls" };
|
|
224
|
+
return "suspended";
|
|
225
|
+
}
|
|
226
|
+
live.nextMessage = undefined;
|
|
227
|
+
if (next.value.done)
|
|
228
|
+
break;
|
|
229
|
+
const message = next.value.value;
|
|
230
|
+
if (request.includeRawChunks === true)
|
|
231
|
+
yield { type: "raw", value: message };
|
|
232
|
+
for (const candidate of live.translate(message)) {
|
|
233
|
+
if ((candidate.type === "tool-call" || candidate.type === "tool-result") &&
|
|
234
|
+
live.toolBridge?.isCursorCall(candidate.id)) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const event = response.accept(candidate);
|
|
238
|
+
if (event === undefined)
|
|
239
|
+
continue;
|
|
240
|
+
if (event.type === "usage")
|
|
241
|
+
live.sawUsage = true;
|
|
242
|
+
yield event;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const result = await live.run.wait();
|
|
246
|
+
if (result.status === "cancelled") {
|
|
247
|
+
yield { type: "failed", error: { kind: "cancelled" } };
|
|
248
|
+
return "finished";
|
|
249
|
+
}
|
|
250
|
+
if (!live.sawUsage && result.usage !== undefined)
|
|
251
|
+
yield usageEvent(result.usage);
|
|
252
|
+
if (result.status === "error") {
|
|
253
|
+
yield { type: "failed", error: { kind: "agent-run-failed", detail: result.error?.message ?? result.id } };
|
|
254
|
+
return "finished";
|
|
255
|
+
}
|
|
256
|
+
if (request.scope && live.plan.kind !== "ONE_SHOT") {
|
|
257
|
+
ctx.bindings.put({
|
|
258
|
+
sessionID: request.scope.sessionID,
|
|
259
|
+
agentID: live.session.id,
|
|
260
|
+
modelID: request.modelID,
|
|
261
|
+
cwd: request.scope.cwd,
|
|
262
|
+
checkpoint: checkpointOf(withResponse(request.conversation, response.parts())),
|
|
263
|
+
params: request.params,
|
|
264
|
+
mode: request.mode,
|
|
265
|
+
agentOptions: request.agentOptions,
|
|
266
|
+
lastUsedAt: nowMs(ctx.clock),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
yield {
|
|
270
|
+
type: "done",
|
|
271
|
+
reason: "stop",
|
|
272
|
+
metadata: {
|
|
273
|
+
runId: result.id,
|
|
274
|
+
...(result.requestId === undefined ? {} : { requestId: result.requestId }),
|
|
275
|
+
...(result.durationMs === undefined ? {} : { durationMs: result.durationMs }),
|
|
276
|
+
...(result.model === undefined ? {} : { modelId: result.model.id }),
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
return "finished";
|
|
280
|
+
}
|
|
281
|
+
finally {
|
|
282
|
+
request.signal?.removeEventListener("abort", cancel);
|
|
283
|
+
await cancelPromise;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
async function closeLiveRun(live, reason) {
|
|
287
|
+
live.toolBridge?.cancel(reason);
|
|
288
|
+
if (live.run.supports("cancel"))
|
|
289
|
+
await live.run.cancel().catch(() => { });
|
|
290
|
+
await live.nextMessage?.catch(() => { });
|
|
291
|
+
await live.session.dispose();
|
|
292
|
+
}
|
|
161
293
|
function withResponse(conversation, parts) {
|
|
162
294
|
if (parts.length === 0)
|
|
163
295
|
return conversation;
|
package/dist/catalog/catalog.js
CHANGED
|
@@ -47,7 +47,7 @@ function writeModel(draft, catalogID, model) {
|
|
|
47
47
|
entry.modelID = Model.ID.make(model.wireID);
|
|
48
48
|
entry.providerID = defaults.providerID;
|
|
49
49
|
entry.name = model.name;
|
|
50
|
-
entry.capabilities = { tools:
|
|
50
|
+
entry.capabilities = { tools: true, input: ["text", "image"], output: ["text"] };
|
|
51
51
|
entry.variants = toCatalogVariants(model);
|
|
52
52
|
entry.time = defaults.time;
|
|
53
53
|
entry.cost = defaults.cost;
|
|
@@ -95,9 +95,6 @@ function appendReasoning(content, delta) {
|
|
|
95
95
|
content.push({ type: "reasoning", text: delta });
|
|
96
96
|
}
|
|
97
97
|
function parseCall(options, modelID, params) {
|
|
98
|
-
if (options.tools !== undefined && options.tools.length > 0) {
|
|
99
|
-
refuse({ kind: "unsupported-request", reason: "tools-requested" });
|
|
100
|
-
}
|
|
101
98
|
if (options.toolChoice !== undefined && options.toolChoice.type !== "auto") {
|
|
102
99
|
refuse({ kind: "unsupported-request", reason: "tool-choice" });
|
|
103
100
|
}
|
|
@@ -132,16 +129,51 @@ function parseCall(options, modelID, params) {
|
|
|
132
129
|
const extracted = extractScope(system);
|
|
133
130
|
const conversation = { system: extracted.system, turns };
|
|
134
131
|
const cursor = parseCursorOptions(options.providerOptions?.cursor);
|
|
132
|
+
const tools = parseTools(options.tools);
|
|
135
133
|
return {
|
|
136
134
|
modelID,
|
|
137
135
|
scope: extracted.scope,
|
|
138
136
|
conversation,
|
|
137
|
+
...(tools.length === 0 ? {} : { tools }),
|
|
139
138
|
...cursor,
|
|
140
139
|
...(options.includeRawChunks === true ? { includeRawChunks: true } : {}),
|
|
141
140
|
...(params === undefined ? {} : { params }),
|
|
142
141
|
...(options.abortSignal === undefined ? {} : { signal: options.abortSignal }),
|
|
143
142
|
};
|
|
144
143
|
}
|
|
144
|
+
function parseTools(tools) {
|
|
145
|
+
if (tools === undefined)
|
|
146
|
+
return [];
|
|
147
|
+
const parsed = [];
|
|
148
|
+
for (const tool of tools) {
|
|
149
|
+
if (tool.type !== "function")
|
|
150
|
+
refuse({ kind: "unsupported-request", reason: "tools-requested" });
|
|
151
|
+
const inputSchema = sdkJsonValue(tool.inputSchema);
|
|
152
|
+
if (typeof inputSchema !== "object" || inputSchema === null || Array.isArray(inputSchema)) {
|
|
153
|
+
refuse({ kind: "unsupported-request", reason: "tools-requested" });
|
|
154
|
+
}
|
|
155
|
+
parsed.push({
|
|
156
|
+
name: tool.name,
|
|
157
|
+
...(tool.description === undefined ? {} : { description: tool.description }),
|
|
158
|
+
inputSchema,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return parsed;
|
|
162
|
+
}
|
|
163
|
+
function sdkJsonValue(value) {
|
|
164
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
165
|
+
return value;
|
|
166
|
+
if (typeof value === "number")
|
|
167
|
+
return Number.isFinite(value) ? value : String(value);
|
|
168
|
+
if (Array.isArray(value))
|
|
169
|
+
return value.map(sdkJsonValue);
|
|
170
|
+
if (typeof value === "object") {
|
|
171
|
+
return Object.fromEntries(Object.entries(value)
|
|
172
|
+
.filter((entry) => entry[1] !== undefined)
|
|
173
|
+
.map(([key, item]) => [key, sdkJsonValue(item)]));
|
|
174
|
+
}
|
|
175
|
+
return String(value);
|
|
176
|
+
}
|
|
145
177
|
function assistantParts(content) {
|
|
146
178
|
const parts = [];
|
|
147
179
|
for (const part of content) {
|
|
@@ -352,6 +384,18 @@ function toStreamParts(events, abort, warnings) {
|
|
|
352
384
|
});
|
|
353
385
|
tools.set(event.id, "called");
|
|
354
386
|
break;
|
|
387
|
+
case "tool-request":
|
|
388
|
+
if (tools.has(event.id))
|
|
389
|
+
break;
|
|
390
|
+
closeOpenParts();
|
|
391
|
+
controller.enqueue({
|
|
392
|
+
type: "tool-call",
|
|
393
|
+
toolCallId: event.id,
|
|
394
|
+
toolName: event.name,
|
|
395
|
+
input: JSON.stringify(event.input),
|
|
396
|
+
});
|
|
397
|
+
tools.set(event.id, "called");
|
|
398
|
+
break;
|
|
355
399
|
case "tool-result":
|
|
356
400
|
if (tools.get(event.id) === "completed")
|
|
357
401
|
break;
|
|
@@ -535,5 +579,7 @@ function reasonFrom(reason) {
|
|
|
535
579
|
return { unified: "stop", raw: reason };
|
|
536
580
|
if (reason === "length")
|
|
537
581
|
return { unified: "length", raw: reason };
|
|
582
|
+
if (reason === "tool-calls")
|
|
583
|
+
return { unified: "tool-calls", raw: reason };
|
|
538
584
|
return { unified: "other", raw: reason };
|
|
539
585
|
}
|
package/dist/plugin.d.ts
CHANGED
|
@@ -4,7 +4,12 @@ export interface ModelWatcherDependencies {
|
|
|
4
4
|
onLinked(listener: () => void): () => void;
|
|
5
5
|
subscribe(signal: AbortSignal): AsyncIterable<{
|
|
6
6
|
readonly type: string;
|
|
7
|
+
readonly data?: unknown;
|
|
7
8
|
}>;
|
|
9
|
+
onEvent?(event: {
|
|
10
|
+
readonly type: string;
|
|
11
|
+
readonly data?: unknown;
|
|
12
|
+
}): Promise<void> | void;
|
|
8
13
|
refresh(): Promise<void>;
|
|
9
14
|
close(): Promise<void>;
|
|
10
15
|
}
|
package/dist/plugin.js
CHANGED
|
@@ -4,7 +4,7 @@ import { createSessionAgentBridge } from "./bridge/bridge.js";
|
|
|
4
4
|
import { sessionFromId } from "./bridge/correlation.js";
|
|
5
5
|
import { applyModels, applyProvider, modelParamsFromOptions } from "./catalog/catalog.js";
|
|
6
6
|
import { createModelSource } from "./catalog/source.js";
|
|
7
|
-
import { asCatalogModelID, PROVIDER_ID } from "./ids.js";
|
|
7
|
+
import { asCatalogModelID, asSessionID, PROVIDER_ID } from "./ids.js";
|
|
8
8
|
import { toLanguageModel } from "./model/language-model.js";
|
|
9
9
|
import { bindRuntime } from "./runtime.js";
|
|
10
10
|
const REFRESH_EVERY_MS = 5_000;
|
|
@@ -42,7 +42,6 @@ export const plugin = Plugin.define({
|
|
|
42
42
|
applyModels(draft, models.list());
|
|
43
43
|
}),
|
|
44
44
|
await ctx.session.hook("context", async (event) => {
|
|
45
|
-
event.tools = {};
|
|
46
45
|
if (String(event.agent) === "compaction" || String(event.agent) === "title")
|
|
47
46
|
return;
|
|
48
47
|
const stamped = bridge.annotate(event.system.map((part) => part.text), sessionFromId(event.sessionID, await sessionCwd(ctx, event.sessionID)));
|
|
@@ -62,11 +61,17 @@ export const plugin = Plugin.define({
|
|
|
62
61
|
const stopRefresh = watchModels({
|
|
63
62
|
onLinked: (listener) => link.onLinked(listener),
|
|
64
63
|
subscribe: (signal) => ctx.event.subscribe({ signal }),
|
|
64
|
+
onEvent: async (event) => {
|
|
65
|
+
const sessionID = endedSessionID(event);
|
|
66
|
+
if (sessionID !== undefined)
|
|
67
|
+
await bridge.cancel(asSessionID(sessionID), "The OpenCode session stopped");
|
|
68
|
+
},
|
|
65
69
|
refresh: () => models.refresh(),
|
|
66
70
|
close: () => models.close(),
|
|
67
71
|
});
|
|
68
72
|
return async () => {
|
|
69
73
|
await stopRefresh();
|
|
74
|
+
await bridge.dispose();
|
|
70
75
|
for (const registration of registrations.toReversed()) {
|
|
71
76
|
await registration.dispose();
|
|
72
77
|
}
|
|
@@ -118,6 +123,7 @@ export function watchModels(dependencies) {
|
|
|
118
123
|
const eventTask = (async () => {
|
|
119
124
|
try {
|
|
120
125
|
for await (const event of dependencies.subscribe(events.signal)) {
|
|
126
|
+
await dependencies.onEvent?.(event);
|
|
121
127
|
if (event.type === "credential.updated" || event.type === "credential.switched")
|
|
122
128
|
refresh();
|
|
123
129
|
}
|
|
@@ -147,3 +153,10 @@ export function watchModels(dependencies) {
|
|
|
147
153
|
return cleanup;
|
|
148
154
|
};
|
|
149
155
|
}
|
|
156
|
+
function endedSessionID(event) {
|
|
157
|
+
if (event.type !== "session.idle" && event.type !== "session.deleted")
|
|
158
|
+
return undefined;
|
|
159
|
+
if (typeof event.data !== "object" || event.data === null || !("sessionID" in event.data))
|
|
160
|
+
return undefined;
|
|
161
|
+
return typeof event.data.sessionID === "string" ? event.data.sessionID : undefined;
|
|
162
|
+
}
|