Skip to content

Commit c057654

Browse files
authored
Merge pull request #184 from oratis/codex/runtime-host
Runtime: bind host safety services behind RuntimeHost
2 parents af9580e + a123484 commit c057654

10 files changed

Lines changed: 265 additions & 54 deletions

File tree

apps/cli/src/headless.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
EFFORT_PARAMS,
2222
HookDispatcher,
2323
ReadTool,
24+
RuntimeHost,
2425
SessionManager,
2526
ToolRegistry,
2627
WebFetchTool,
@@ -39,7 +40,6 @@ import {
3940
loadSkills,
4041
makeSkillTool,
4142
resolveCredentials,
42-
runAgent,
4343
wirePlugins,
4444
collectPluginContributions,
4545
type AgentEvent,
@@ -271,25 +271,28 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
271271
}
272272
let exitCode = 0;
273273
try {
274-
const result = await runAgent({
274+
const runtime = new RuntimeHost({
275275
provider,
276276
tools,
277+
cwd,
278+
mode,
279+
permissions: settings.permissions,
280+
hooks,
281+
pluginDirs: pluginContrib.dirs,
282+
autoMode: settings.autoMode,
283+
sandboxConfig: settings.sandbox,
284+
});
285+
const result = await runtime.run({
277286
systemPrompt,
278287
userMessage,
279288
history: [],
280289
model,
281290
maxTokens,
282291
temperature,
283292
maxTurns,
284-
cwd,
293+
signal: ctrl.signal,
285294
session: { manager: sessions, id: session.id },
286-
mode,
287-
permissions: settings.permissions,
288-
hooks,
289-
pluginDirs: pluginContrib.dirs,
290295
autoCompact: { contextWindow: contextWindowFor(model), threshold: 0.8 },
291-
autoMode: settings.autoMode,
292-
sandboxConfig: settings.sandbox,
293296
// In headless mode there's no human to ask: auto-deny anything that
294297
// would normally need approval. Users wanting auto-yes should pass
295298
// --mode dontAsk or --mode bypassPermissions (gated by trust).

apps/cli/src/repl.ts

Lines changed: 28 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
EFFORT_PARAMS,
99
HookDispatcher,
1010
ReadTool,
11+
RuntimeHost,
1112
SessionManager,
1213
TaskManager,
1314
ToolRegistry,
@@ -37,7 +38,6 @@ import {
3738
contextWindowFor,
3839
makeSkillTool,
3940
resolveCredentials,
40-
runAgent,
4141
settingsPaths,
4242
wirePlugins,
4343
collectPluginContributions,
@@ -429,6 +429,17 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
429429
}
430430

431431
let history: StoredMessage[] = resolved.seededHistory;
432+
const runtime = new RuntimeHost({
433+
provider,
434+
tools,
435+
cwd,
436+
mode,
437+
permissions: settings.permissions,
438+
hooks,
439+
pluginDirs: pluginContrib.dirs,
440+
autoMode: settings.autoMode,
441+
sandboxConfig: settings.sandbox,
442+
});
432443
const ctx: SessionContext = {
433444
cwd,
434445
model,
@@ -471,25 +482,20 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
471482
// reading ctx.model/ctx.mode live so /model and /mode switches are honored.
472483
const tasks = new TaskManager((spec) => {
473484
const ac = new AbortController();
474-
const done = runAgent({
475-
provider,
476-
tools,
477-
systemPrompt,
478-
userMessage: spec.prompt,
479-
model: ctx.model,
480-
maxTokens,
481-
temperature,
482-
cwd: ctx.cwd,
483-
signal: ac.signal,
484-
mode: ctx.mode as Mode,
485-
permissions: settings.permissions,
486-
hooks,
487-
pluginDirs: pluginContrib.dirs,
488-
sandboxConfig: settings.sandbox,
489-
autoMode: settings.autoMode,
490-
subAgentDepth: 1,
491-
systemReminders: false,
492-
}).then((r) => assistantText(r.history));
485+
const done = runtime
486+
.run({
487+
systemPrompt,
488+
userMessage: spec.prompt,
489+
model: ctx.model,
490+
maxTokens,
491+
temperature,
492+
cwd: ctx.cwd,
493+
signal: ac.signal,
494+
modeOverride: ctx.mode as Mode,
495+
subAgentDepth: 1,
496+
systemReminders: false,
497+
})
498+
.then((r) => assistantText(r.history));
493499
return { done, abort: () => ac.abort() };
494500
});
495501
ctx.tasks = tasks;
@@ -649,9 +655,7 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
649655
}
650656

651657
// Otherwise: send to agent (with mode/permission/hooks gating from M3b)
652-
const result = await runAgent({
653-
provider,
654-
tools,
658+
const result = await runtime.run({
655659
systemPrompt,
656660
userMessage: userInput,
657661
history,
@@ -663,13 +667,8 @@ export async function startRepl(opts: ReplOpts): Promise<number> {
663667
// ctx.sessionId (not the launch `session.id`) so a live `/resume <id>`
664668
// switch redirects new messages to the resumed session.
665669
session: { manager: sessions, id: ctx.sessionId },
666-
mode: ctx.mode as Mode,
667-
permissions: settings.permissions,
668-
hooks,
669-
pluginDirs: pluginContrib.dirs,
670+
modeOverride: ctx.mode as Mode,
670671
autoCompact: { contextWindow: contextWindowFor(ctx.model), threshold: 0.8 },
671-
autoMode: settings.autoMode,
672-
sandboxConfig: settings.sandbox,
673672
// Session-scoped manager: the agent's TaskCreate calls land here too, so
674673
// background tasks persist across turns and show up in /tasks.
675674
taskManager: tasks,

apps/lsp/src/handler.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,12 @@ async function handleRunAgent(
132132
void (async () => {
133133
try {
134134
const [
135-
{ runAgent },
135+
{ RuntimeHost },
136136
{ DeepSeekProvider },
137137
{ ToolRegistry, BUILTIN_TOOLS, SAFE_READONLY_TOOLS },
138138
{ resolveCredentials, CredentialsStore },
139139
] = await Promise.all([
140-
import('@deepcode/core').then((m) => ({ runAgent: m.runAgent })),
140+
import('@deepcode/core').then((m) => ({ RuntimeHost: m.RuntimeHost })),
141141
import('@deepcode/core').then((m) => ({ DeepSeekProvider: m.DeepSeekProvider })),
142142
import('@deepcode/core').then((m) => ({
143143
ToolRegistry: m.ToolRegistry,
@@ -163,16 +163,18 @@ async function handleRunAgent(
163163
baseURL: creds.baseURL,
164164
});
165165

166-
const result = await runAgent({
166+
const runtime = new RuntimeHost({
167167
provider,
168168
tools: new ToolRegistry(BUILTIN_TOOLS),
169+
cwd: state.rootUri ? new URL(state.rootUri).pathname : process.cwd(),
170+
mode: 'default',
171+
permissions: { allow: [...SAFE_READONLY_TOOLS] },
172+
});
173+
const result = await runtime.run({
169174
systemPrompt: 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise.',
170175
userMessage: args.prompt!,
171176
model: args.model ?? 'deepseek-chat',
172-
cwd: state.rootUri ? new URL(state.rootUri).pathname : process.cwd(),
173177
signal: abortController.signal,
174-
mode: 'default',
175-
permissions: { allow: [...SAFE_READONLY_TOOLS] },
176178
onEvent: (e) => {
177179
send({
178180
jsonrpc: '2.0',

apps/vscode/src/extension.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,15 +89,17 @@ async function runAgent(
8989
authToken: creds.authToken,
9090
baseURL: creds.baseURL,
9191
});
92-
await core.runAgent({
92+
const runtime = new core.RuntimeHost({
9393
provider,
9494
tools: new core.ToolRegistry(core.BUILTIN_TOOLS),
95-
systemPrompt: 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise.',
96-
userMessage,
97-
model: 'deepseek-chat',
9895
cwd,
9996
mode: 'default',
10097
permissions: { allow: [...core.SAFE_READONLY_TOOLS] },
98+
});
99+
await runtime.run({
100+
systemPrompt: 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise.',
101+
userMessage,
102+
model: 'deepseek-chat',
101103
onEvent: (e) => {
102104
if (e.type === 'text_delta') out.append(e.text);
103105
else if (e.type === 'tool_use') out.appendLine(`\n[${e.name}] ${formatInput(e.input)}`);
@@ -157,15 +159,17 @@ class ChatViewProvider implements vscode.WebviewViewProvider {
157159
baseURL: creds.baseURL,
158160
});
159161
let buffer = '';
160-
await core.runAgent({
162+
const runtime = new core.RuntimeHost({
161163
provider,
162164
tools: new core.ToolRegistry(core.BUILTIN_TOOLS),
163-
systemPrompt: 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise.',
164-
userMessage: msg.text,
165-
model: 'deepseek-chat',
166165
cwd: this.vscodeMod.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(),
167166
mode: 'default',
168167
permissions: { allow: [...core.SAFE_READONLY_TOOLS] },
168+
});
169+
await runtime.run({
170+
systemPrompt: 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise.',
171+
userMessage: msg.text,
172+
model: 'deepseek-chat',
169173
onEvent: (e) => {
170174
if (e.type === 'text_delta') {
171175
buffer += e.text;

packages/core/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ DeepCode 的 TypeScript 内核包:agent loop、DeepSeek provider、tools、con
66
77
## 当前状态
88

9-
主要模块均已有实现与测试。当前最重要的已知限制不是“缺少骨架”,而是不同 host 对 `runAgent` 的组装不一致:CLI 传入完整 permissions/hooks/sandbox/session/task services,desktop、LSP 与 VS Code 只传入其中一部分。后续通过不可绕过的 `RuntimeHost` 收敛,而不是继续增加 host-specific wiring
9+
主要模块均已有实现与测试。CLI、headless、LSP 与 VS Code 已通过 `RuntimeHost` 固定 provider、tools、permissionshookssandbox 等安全服务;`runAgent` 保留为 core 内部循环和 desktop 迁移期兼容入口。当前剩余的主要 host 差异是 desktop renderer 仍直接运行 provider/loop,后续按 packaging ADR 迁出 WebView
1010

1111
关键入口:
1212

packages/core/src/agent.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { TaskManager, type TaskRunner } from './tasks/manager.js';
88
import type { HookDispatcher } from './hooks/index.js';
99
import type { Mode } from './types.js';
1010
import type { Provider } from './providers/types.js';
11-
import { resolveRuntimePolicy } from './runtime/index.js';
11+
import { resolveRuntimePolicy } from './runtime/policy.js';
1212
// NOTE: reminders + sessions are lazy-loaded inside the loop so a browser
1313
// build (Tauri renderer) that doesn't use them avoids pulling node:fs at
1414
// module-load time. See `loadRemindersIfEnabled` and `appendSessionIfSet`.

packages/core/src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,11 @@ export type { ApprovalCallback, ApprovalDecision } from './agent.js';
181181
export {
182182
SAFE_DEFAULT_PERMISSIONS,
183183
SAFE_READONLY_TOOLS,
184+
RuntimeHost,
185+
createRuntimeHost,
184186
resolveRuntimePolicy,
187+
type RuntimeHostOptions,
188+
type RuntimeTurnOptions,
185189
type RuntimePolicyInput,
186190
} from './runtime/index.js';
187191

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { Provider, ProviderResult, ProviderRunOpts } from '../providers/types.js';
3+
import { ToolRegistry } from '../tools/registry.js';
4+
import type { ToolHandler } from '../types.js';
5+
import { RuntimeHost } from './host.js';
6+
7+
class ScriptedProvider implements Provider {
8+
readonly name = 'scripted';
9+
constructor(private readonly results: ProviderResult[]) {}
10+
async runTurn(_opts: ProviderRunOpts): Promise<ProviderResult> {
11+
const result = this.results.shift();
12+
if (!result) throw new Error('no scripted result');
13+
return result;
14+
}
15+
}
16+
17+
const usage = { inputTokens: 1, outputTokens: 1, reasoningTokens: 0, cacheReadTokens: 0 };
18+
19+
function writeThenDone(): ProviderResult[] {
20+
return [
21+
{
22+
content: [
23+
{
24+
type: 'tool_use',
25+
id: 'write-1',
26+
name: 'Write',
27+
input: { file_path: 'x', content: 'x' },
28+
},
29+
],
30+
stopReason: 'tool_use',
31+
usage,
32+
},
33+
{
34+
content: [{ type: 'text', text: 'done' }],
35+
stopReason: 'end_turn',
36+
usage,
37+
},
38+
];
39+
}
40+
41+
describe('RuntimeHost', () => {
42+
it('fails closed when the host omits policy and approval', async () => {
43+
let executions = 0;
44+
const write: ToolHandler = {
45+
name: 'Write',
46+
definition: { name: 'Write', description: 'test', inputSchema: {} },
47+
execute: async () => {
48+
executions++;
49+
return { content: 'wrote' };
50+
},
51+
};
52+
const host = new RuntimeHost({
53+
provider: new ScriptedProvider(writeThenDone()),
54+
tools: new ToolRegistry([write]),
55+
cwd: '/tmp',
56+
});
57+
58+
const result = await host.run({
59+
systemPrompt: '',
60+
userMessage: 'write',
61+
model: 'deepseek-chat',
62+
systemReminders: false,
63+
});
64+
65+
expect(executions).toBe(0);
66+
expect(result.history.flatMap((message) => message.content)).toContainEqual(
67+
expect.objectContaining({ type: 'tool_result', is_error: true }),
68+
);
69+
});
70+
71+
it('keeps host policy while accepting an explicit turn mode override', async () => {
72+
let executions = 0;
73+
const write: ToolHandler = {
74+
name: 'Write',
75+
definition: { name: 'Write', description: 'test', inputSchema: {} },
76+
execute: async () => {
77+
executions++;
78+
return { content: 'wrote' };
79+
},
80+
};
81+
const host = new RuntimeHost({
82+
provider: new ScriptedProvider(writeThenDone()),
83+
tools: new ToolRegistry([write]),
84+
cwd: '/tmp',
85+
mode: 'default',
86+
});
87+
88+
await host.run({
89+
systemPrompt: '',
90+
userMessage: 'write',
91+
model: 'deepseek-chat',
92+
systemReminders: false,
93+
modeOverride: 'bypassPermissions',
94+
});
95+
96+
expect(executions).toBe(1);
97+
expect(host.mode).toBe('default');
98+
});
99+
100+
it('requires a cwd at either boundary', () => {
101+
const host = new RuntimeHost({
102+
provider: new ScriptedProvider([]),
103+
tools: new ToolRegistry(),
104+
});
105+
expect(() => host.run({ systemPrompt: '', userMessage: 'x', model: 'deepseek-chat' })).toThrow(
106+
/requires cwd/,
107+
);
108+
});
109+
});

0 commit comments

Comments
 (0)