Add initial scope in agents file

This commit is contained in:
Lars Baunwall 2025-08-12 18:36:24 +02:00
commit 112475def8
No known key found for this signature in database

410
AGENTS.md Normal file
View file

@ -0,0 +1,410 @@
Goals
1. Bridge Copilot Chat to an OpenAIstyle API so other local tools can “speak OpenAI” and get Copilot responses.
2. Provide optional tools (search/read/patch/format) over a JSONRPC IPC to enable plan→act loops from external orchestrators.
3. Run only inside a users running VS Code Desktop session—no server/daemon install, no VS Code Server dependency.
4. Respect safety & UX: localonly networking by default, explicit enable/disable, minimal footprint.
Nongoals
• No direct calls to private Copilot backends.
• No multitenant proxying or remote exposure.
• No attempt to fully emulate OpenAI “tools/function calling” (unsupported by Copilot provider).
Highlevel architecture
VS Code Desktop (running)
┌────────────────────────────────────────────────────────────────┐
│ Bridge Extension (Node in Extension Host) │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ OpenAI Facade (HTTP, 127.0.0.1:PORT) │ │
│ │ - /v1/chat/completions (SSE) │ │
│ │ - /v1/models │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ JSON-RPC IPC (WebSocket, 127.0.0.1:PORT2) │ │
│ │ - mcp.fs.read / list │ │
│ │ - mcp.search.code │ │
│ │ - mcp.edit.applyPatch / format / organizeImports │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Copilot Chat Pipe │ │
│ │ vscode.chat.requestChatAccess('copilot') │ │
│ │ access.startSession().sendRequest({ prompt, ... }) │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Workspace APIs │ │
│ │ findTextInFiles / findFiles / WorkspaceEdit / tasks │ │
│ └──────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
Data flows
Chat (OpenAI facade)
1. Client → POST /v1/chat/completions (OpenAIshape, SSE requested).
2. Extension concatenates recent messages → prompt.
3. requestChatAccess('copilot') → startSession() → sendRequest({ prompt }).
4. Stream Copilot chunks → map to OpenAI SSE (data: {object:"chat.completion.chunk", ...}).
5. On end → send data: [DONE].
Tools (optional, JSONRPC)
1. Client → mcp.search.code / mcp.fs.read / mcp.edit.applyPatch.
2. Extension executes via VS Code APIs (read/find/apply/format).
3. Returns structured results/errors.
API contracts
OpenAIstyle /v1/chat/completions (subset)
Request
{
"model": "gpt-4o-copilot",
"stream": true,
"messages": [
{"role":"system","content":"You are a cautious coding assistant."},
{"role":"user","content":"Refactor retry logic in PaymentClient."}
]
}
SSE Response
HTTP 200
Content-Type: text/event-stream
data: {"id":"cmp_...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"Plan: "}}]}
data: {"id":"cmp_...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Update backoff…"}}]}
...
data: [DONE]
Notes
• Ignore unsupported fields (tools/function_call/logprobs/seed).
• model is passthrough (synthetic id), Copilot selects internals.
• Enforce small history window to keep prompts bounded.
JSONRPC (WebSocket), examples
Request
{"jsonrpc":"2.0","id":"1","method":"mcp.search.code","params":{"query":"class PaymentClient","glob":"src/**/*.ts","maxResults":200}}
Result
{"jsonrpc":"2.0","id":"1","result":{"hits":[{"file":"src/payment/Client.ts","line":12,"snippet":"class PaymentClient { ... }"}]}}
Edit apply
{"jsonrpc":"2.0","id":"2","method":"mcp.edit.applyPatch","params":{"unifiedDiff":"--- a/src/.."}}
Extension design
package.json (key points)
• "activationEvents": ["onStartupFinished"] or a command palette toggle.
• "contributes.commands":
• bridge.enable, bridge.disable (start/stop servers)
• bridge.status (port info, copilot availability)
• "contributes.configuration": bridge.openai.port, bridge.rpc.port, bridge.bindAddress (default 127.0.0.1), bridge.readOnly (default true).
Lifecycle
• On activate:
• Check user setting bridge.enabled.
• Verify Copilot availability (requestChatAccess('copilot')), store a lazy accessor.
• Start OpenAI facade server (HTTP) and RPC server (WS) with localhost binding.
• Surface a status bar item: “Bridge: ON (127.0.0.1:PORT)”.
• On deactivate or bridge.disable:
• Close servers, dispose listeners.
Copilot Chat Pipe
• Keep no longlived chat session—create per request:
const access = await vscode.chat.requestChatAccess('copilot');
const session = await access.startSession();
const stream = await session.sendRequest({ prompt, attachments: [] });
• Subscribe to streaming events and forward to SSE.
OpenAI Facade (HTTP, inside extension)
• Implement with http or express (simple router).
• Endpoints:
• POST /v1/chat/completions → SSE
• GET /v1/models → synthetic listing
• GET /healthz → { ok, copilot: "ok|unavailable" }
• Message normalization:
• Concatenate messages into a single prompt:
• Keep latest system and last N user/assistant turns (N configurable, default 3).
• Format as:
[SYSTEM]
...
[DIALOG]
user: ...
assistant: ...
user: ...
• SSE mapping: each Copilot chunk → one OpenAI delta with content text; close with [DONE].
JSONRPC Tool Bus (WebSocket)
• Methods (prefix mcp.):
• fs.read({path}) -> {content, sha256}
• fs.list({glob,limit}) -> {files[]}
• search.code({query,glob,maxResults}) -> {hits[]} using findTextInFiles
• symbols.list({path}) -> {symbols[]}
• edit.applyPatch({unifiedDiff,verify?true}) -> {ok, conflicts[]}
• format.apply({path}) -> {ok}
• imports.organize({path}) -> {ok}
• (optional) task.run({name}), shell.run({cmd}) (guarded)
• Edit safety:
• Default readonly; require explicit setting bridge.readOnly=false or a consent prompt to enable writes.
• Maintain preimage verification: compute hashes for target ranges before applying WorkspaceEdit.
• Always save file after apply; optionally run format and organizeImports.
Policy controls
• .agent-policy.yaml in workspace root:
writes:
allow: ["src/**/*.ts","test/**/*.ts"]
deny: ["**/node_modules/**","**/dist/**"]
shell:
allow: ["npm test","dotnet test"]
• Reject edit.applyPatch and shell.run if policy denies.
Security defaults
• Bind servers to 127.0.0.1 only.
• Random ephemeral ports on first run; store in globalState.
• Optional bearer token for the OpenAI facade (bridge.token); otherwise restrict to loopback.
• No persistence of Copilot tokens; VS Code manages auth.
Implementation notes (guidance for the AI agent)
1) Create extension skeleton
• Use yo code (TypeScript).
• Add deps: ws (WebSocket), optionally express, yaml.
• Wire commands bridge.enable/disable/status.
2) Copilot availability check
let chatAccess: vscode.ChatAccess | undefined;
async function ensureCopilotAccess() {
try { chatAccess = await vscode.chat.requestChatAccess('copilot'); }
catch { chatAccess = undefined; }
return !!chatAccess;
}
3) OpenAI facade server (SSE)
• Start HTTP server on 127.0.0.1:<port>.
• For /v1/chat/completions:
• Validate messages, stream === true; if not streaming, still return a single chunk then [DONE].
• Build prompt string.
• const session = await chatAccess!.startSession(); const stream = await session.sendRequest({ prompt }).
• Map stream.onDidProduceContent → res.write("data: <chunk>\n\n") with OpenAI envelope.
• On end/error → res.write("data: [DONE]\n\n"); res.end();.
4) JSONRPC WS server
• Start ws.Server({ host:"127.0.0.1", port }).
• Envelope:
• Validate jsonrpc, id, method.
• Dispatch to handlers with params.
• Handlers:
• search.code: call vscode.workspace.findTextInFiles with include pattern; for each match, collect file path, line, short snippet (e.g., ±3 lines).
• edit.applyPatch: parse unified diff → WorkspaceEdit:
• Open doc, compute line ranges, verify preimage (optional hash in diff metadata), apply replacements, save.
• After apply: vscode.commands.executeCommand('editor.action.formatDocument') and organize imports (languagespecific commands).
• Normalize paths with vscode.Uri.file.
5) Diff parsing & preimage checks
• Import a small unified diff parser or implement:
• Parse files as --- a/… / +++ b/…, hunks @@ -l,s +l,s @@.
• For each hunk, compute target ranges and replacement text.
• Preimage:
• Option A: lightweight—compare expected lines in hunk with current doc slice.
• Option B: embed span hashes in a custom header in the diff; verify before apply.
6) UX glue
• Status bar item shows: “Bridge: ON · Chat: OK/Unavailable”.
• Output channel “Copilot Bridge” for logs and port info.
• Command bridge.status dumps:
• Copilot avail
• OpenAI endpoint URL
• RPC endpoint URL
• Policy: readonly/writable
7) Error handling
• Map Copilot errors to 5xx in OpenAI facade with JSON payload:
{"error":{"message":"Copilot unavailable","type":"server_error","code":"copilot_unavailable"}}
• For JSONRPC: return {"error":{"code":<int>,"message":"…"}}.
8) Testing strategy
• Unit: diff parser, search aggregations.
• Integration:
• Start VS Code, enable bridge, curl -N http://127.0.0.1:<port>/v1/chat/completions with a trivial prompt → streamed chunks.
• JSONRPC: create a sample workspace, run search.code, assert hits; run edit.applyPatch with a known diff, assert file content and formatting.
Example stubs
extension.ts (skeleton)
import * as vscode from 'vscode';
import { createHttpFacade } from './http/openai';
import { createRpcServer } from './rpc/server';
let httpClose: (() => Promise<void>) | undefined;
let rpcClose: (() => Promise<void>) | undefined;
let chatAccess: vscode.ChatAccess | undefined;
export async function activate(ctx: vscode.ExtensionContext) {
const ok = await ensureCopilot();
const { httpServer, close: closeHttp } = await createHttpFacade(() => chatAccess);
const { server: rpcServer, close: closeRpc } = await createRpcServer(ctx);
httpClose = closeHttp; rpcClose = closeRpc;
vscode.window.setStatusBarMessage(`Bridge: ON · Chat: ${ok ? 'OK' : 'Unavailable'}`);
ctx.subscriptions.push(
vscode.commands.registerCommand('bridge.status', async () => {
const msg = `Chat: ${ok}\nHTTP: ${httpServer.address()}\nRPC: ${rpcServer.address()}`;
vscode.window.showInformationMessage(msg);
}),
{ dispose: async () => { await Promise.all([httpClose?.(), rpcClose?.()]); } }
);
async function ensureCopilot() {
try { chatAccess = await vscode.chat.requestChatAccess('copilot'); return true; }
catch { chatAccess = undefined; return false; }
}
}
http/openai.ts (key pieces)
import * as http from 'http';
import * as vscode from 'vscode';
export function createHttpFacade(getAccess: () => vscode.ChatAccess | undefined) {
const port = pickPort();
const server = http.createServer(async (req, res) => {
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
const access = getAccess();
if (!access) return sendError(res, 503, 'Copilot unavailable');
const body = await readJson(req);
const prompt = normalizeMessages(body.messages);
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
const session = await access.startSession();
const stream = await session.sendRequest({ prompt, attachments: [] });
const id = `cmp_${Math.random().toString(36).slice(2)}`;
const send = (text: string) => {
res.write(`data: ${JSON.stringify({
id, object:'chat.completion.chunk',
choices:[{ index:0, delta:{ content:text } }]
})}\n\n`);
};
const d1 = stream.onDidProduceContent(c => send(c));
const d2 = stream.onDidEnd(() => { res.write(`data: [DONE]\n\n`); res.end(); d1.dispose(); d2.dispose(); });
req.on('close', () => { d1.dispose(); d2.dispose(); });
return;
}
if (req.method === 'GET' && req.url === '/v1/models') {
res.writeHead(200, {'Content-Type':'application/json'});
res.end(JSON.stringify({ data:[{ id:'gpt-4o-copilot', object:'model', owned_by:'vscode-bridge' }] }));
return;
}
res.writeHead(404).end();
});
server.listen(port, '127.0.0.1');
return { httpServer: server, close: async () => new Promise<void>(r => server.close(() => r())) };
}
rpc/server.ts (skeleton)
import WebSocket, { WebSocketServer } from 'ws';
import * as vscode from 'vscode';
import { applyUnifiedDiff } from '../utils/diff';
export function createRpcServer(ctx: vscode.ExtensionContext) {
const port = pickPort();
const wss = new WebSocketServer({ host:'127.0.0.1', port });
wss.on('connection', ws => {
ws.on('message', async (raw) => {
try {
const { id, method, params } = JSON.parse(raw.toString());
if (method === 'mcp.search.code') {
const hits: any[] = [];
await vscode.workspace.findTextInFiles({ pattern: params.query },
{ include: new vscode.RelativePattern(vscode.workspace.workspaceFolders![0], params.glob ?? '**/*'),
maxResults: params.maxResults ?? 200 },
r => hits.push({ file:r.uri.fsPath, line:r.ranges[0].start.line }));
ws.send(JSON.stringify({ jsonrpc:'2.0', id, result:{ hits } }));
return;
}
if (method === 'mcp.edit.applyPatch') {
const ok = await applyUnifiedDiff(params.unifiedDiff);
ws.send(JSON.stringify({ jsonrpc:'2.0', id, result:{ ok } }));
return;
}
ws.send(JSON.stringify({ jsonrpc:'2.0', id, error:{ code:-32601, message:'Method not found' }}));
} catch (e:any) {
ws.send(JSON.stringify({ jsonrpc:'2.0', id:null, error:{ code:-32603, message:e?.message ?? 'Internal error' }}));
}
});
});
ctx.subscriptions.push({ dispose: () => wss.close() });
return { server: wss, close: async () => new Promise<void>(r => { wss.close(() => r()); }) };
}
Configuration & UX
• Settings (bridge.*):
• enabled (bool), bindAddress (default 127.0.0.1), openai.port (int), rpc.port (int), token (string), readOnly (bool).
• Commands:
• Enable/Disable: starts/stops servers and updates status bar.
• Status: shows ports, health, policy state.
• Output channel logs significant events (start/stop, errors).
Testing checklist
1. OpenAI facade
• curl -N -H "Content-Type: application/json" -d '{"model":"gpt-4o-copilot","stream":true,"messages":[{"role":"user","content":"hello"}]}' http://127.0.0.1:<port>/v1/chat/completions
• Expect streamed chunks and [DONE].
2. Search/edit
• Connect WS client; call mcp.search.code for a known string; verify hits.
• Apply a controlled unified diff; verify file saved and formatted.
3. Safety
• With readOnly=true, mcp.edit.applyPatch should return policy error.
• With .agent-policy.yaml denying path, write should be rejected.
4. Resilience
• Kill and restart Copilot auth (sign out/in) → /healthz and facade should report unavailability then recover.
Operational guidance
• Local only: keep default binding to 127.0.0.1; require a token to bind beyond loopback (not recommended).
• Peruser: one desktop session, one bridge. Do not expose externally or share across users.
• No caching to bypass usage controls; treat the bridge as a convenience, not a relay service.