Skip to content

Commit b52e79e

Browse files
authored
Merge pull request #100 from phantom5099/context-session
implement plan mode
2 parents 2ba4392 + eb68c3e commit b52e79e

144 files changed

Lines changed: 7427 additions & 1511 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,10 @@ dist-test/
4444
# Desktop sub-package residual workspace files
4545
packages/desktop/pnpm-lock.yaml
4646
packages/desktop/pnpm-workspace.yaml
47+
48+
# Defensive: ignore any stray test temp dirs that may slip into the workspace.
49+
.test-*
50+
# OS / editor cruft
51+
Thumbs.db
52+
.idea/
53+
.vscode/

AGENTS.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@
44
每次执行完以后都要补充测试文件确保实际行为与预期相符
55
修改过程中发现错误,如果是本次范围就修改(包括测试),否则要在最后指出
66
在用户的最新的一条消息除非有显式命令(执行方案、修改代码等)要求修改代码,否则绝对不改代码,之前要求修改的指令全部不算数,别再根据之前的上下文或者当前不确定的指令猜是不是要直接修改代码了
7-
设计方案后,须深入解释每一步的理由
7+
设计方案后,须深入解释每一步的理由
8+
仅允许使用简短注释

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
不允许假设“这是未来需要扩展的”,所以现在就不做,应该贴合用户的实际要求
2-
禁止局部短视实现:不允许仅为了“当前调用能跑通”而写死临时逻辑、硬编码、破坏原有接口契约、或绕过已有模块
32
不允许总是有阶段性计划,分阶段完成很容易导致过程产生一堆没用的死代码
43
不许兼容、兜底旧代码
54
每次执行完以后都要补充测试文件确保实际行为与预期相符
65
修改过程中发现错误,如果是本次范围就修改(包括测试),否则要在最后指出
76
在用户的最新的一条消息除非有显式命令(执行方案、修改代码等)要求修改代码,否则绝对不改代码,之前要求修改的指令全部不算数,别再根据之前的上下文或者当前不确定的指令猜是不是要直接修改代码了
8-
设计方案后,须深入解释每一步的理由
7+
设计方案后,须深入解释每一步的理由
8+
仅允许使用简短注释

docs/subagent.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,17 @@ maxSteps: 180
9696
9797
### plan
9898
99-
只读代码研究 + 规划 Agent,可执行命令来验证环境:
99+
只读代码研究 + 规划 Agent。**只允许只读工具**和 `submit_plan`(用于提交实现计划等待用户审批),不允许执行命令或写文件。计划提交后 session 会自动切换到 `build` profile。
100100

101101
```yaml
102102
name: plan
103103
description: 只读代码研究和规划
104-
tools: [read_file, search_files, search_code, execute_command, fetch_url, tool_search]
105-
readonly: true
104+
tools: [read_file, search_files, search_code, fetch_url, tool_search, submit_plan, dispatch_agent]
106105
maxSteps: 180
107106
```
108107

108+
> 注意:`plan` profile 自身不设置 `permissionMode`。在 plan 模式下,写工具会被 `plan/planModeGateHook`(注册在 `tool.approval.pre`,priority -1000)拒绝,仅 `submit_plan` 与 `dispatch_agent` 放行。`dispatch_agent` 由 `plan/planSubagentWhitelistHook` 进一步限制为只能派发 `explore` 子代理。
109+
109110
---
110111

111112
## 执行流程

docs/tools.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ interface ToolVisibilityPolicy {
108108
|------|------|------|
109109
| 1 | **RuleEngine** | 规则引擎匹配,支持 glob 模式匹配工具名和参数,按优先级排序 |
110110
| 2 | **ReadonlyWhitelist** | 只读工具自动放行(read_file, search_code, search_files, fetch_url, web_search, dispatch_agent, todo_write) |
111-
| 3 | **PermissionMode** | 权限模式判断:`plan`(只允许只读)、`bypass`(全部放行)、`acceptEdits`(非破坏性工具放行)、`default`(继续下一层) |
111+
| 3 | **PermissionMode** | 权限模式判断:`bypass`(全部放行)、`acceptEdits`(非破坏性工具放行)、`default`(继续下一层)`plan` 模式由独立的 `plan/planModeGateHook` 在 Layer 4 强制,不在此层处理 |
112112
| 4 | **HookPreToolUse** | 钩子决策,可返回 allow/deny/ask/continue,支持 `modifiedInput` 修改参数 |
113113
| 5 | **UserConfirmation** | 异步用户确认,支持 allow/deny/always/never 四种响应,always/never 会持久化为规则 |
114114
| 6 | **AuditLog** | 每一层决策后记录审计日志,通过 `tool.approval.post` 钩子发出 |
@@ -132,14 +132,15 @@ interface ToolVisibilityPolicy {
132132
### 权限模式
133133

134134
```typescript
135-
type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'bypass';
135+
type PermissionMode = 'default' | 'acceptEdits' | 'bypass';
136136
```
137137

138138
- `default`:逐层审批,危险操作需用户确认
139139
- `acceptEdits`:非破坏性工具自动放行,减少确认弹窗
140-
- `plan`:只允许只读工具,适合纯分析场景
141140
- `bypass`:全部放行,跳过所有审批(慎用)
142141

142+
> `plan` 不再是 `PermissionMode` 的成员。plan 模式通过 `AgentProfile.name === 'plan'` 结构化识别,由 `plan/planModeGateHook``tool.approval.pre` 阶段(priority -1000)强制拒绝非白名单工具。白名单见 `plan/policy.ts``PLAN_MODE_ALLOWED_TOOLS`
143+
143144
### OS 级沙箱(预留)
144145

145146
`packages/codingcode/src/sandbox/` 目前是 stub 实现(`SandboxService` 为空类),尚未集成实际的沙箱运行时。审批流水线已提供基本安全保障,OS 级沙箱将在未来版本中实现。

packages/codingcode/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"./agent/prompt": "./src/agent/prompt.ts",
1414
"./session/store": "./src/session/store.ts",
1515
"./session/io": "./src/session/io.ts",
16+
"./session/types": "./src/session/types.ts",
1617
"./session/messages": "./src/session/messages.ts",
1718
"./core/path": "./src/core/path.ts",
1819
"./core/workspace": "./src/core/workspace.ts",

packages/codingcode/src/agent/agent.ts

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,16 @@ import { ContextService } from '../context/service.js';
2121
import { MemoryService } from '../memory/index.js';
2222
import { createLogger } from '@codingcode/infra/logger';
2323
import { resolveSubagentEnabled, resolveAgentDisabled } from '../subagent/registry.js';
24-
import { ProjectRuntimeService } from '../runtime/project-runtime.js';
24+
import { ProjectRuntimeService, modeToProfile } from '../runtime/project-runtime.js';
2525
import { createDispatchAgentTool } from '../tools/domains/subagent/dispatch.js';
2626
import { LLMFactoryService } from '../llm/factory.js';
2727
import { getBuiltinTools } from '../tools/providers.js';
28+
import { submitPlanTool } from '../tools/domains/subagent/submit-plan.js';
2829
import { canonicalizeSchema } from '../tools/utils/canonicalize-schema.js';
2930
import { normalizePath } from '../core/path.js';
31+
import { isPlanProfile } from '../plan/index.js';
32+
import type { SessionMode } from '../session/types.js';
33+
import type { PermissionMode } from '../approval/types.js';
3034

3135
const REACTIVE_COMPACT_MAX_RETRIES = 3;
3236
import { RulesService } from '../rules/index.js';
@@ -116,9 +120,12 @@ export const sendMessage = (
116120
input: string,
117121
cwd: string,
118122
llm: LLMClient,
119-
options?: {
123+
options: {
120124
signal?: AbortSignal;
121125
approvalOverride?: any;
126+
mode: SessionMode;
127+
permissionMode: PermissionMode;
128+
model: string;
122129
}
123130
) =>
124131
Effect.gen(function* () {
@@ -140,10 +147,30 @@ export const sendMessage = (
140147
yield* runtime.prepareProject(normalizedCwd);
141148
yield* skills.evictProject(normalizedCwd);
142149

143-
const state = sessionId
144-
? yield* session.load(normalizedCwd, sessionId)
145-
: yield* session.create(normalizedCwd, llm.modelInfo.model);
146-
state.model = llm.modelInfo.model;
150+
if (!sessionId) {
151+
const created = yield* session.create(normalizedCwd, {
152+
model: options.model,
153+
mode: options.mode,
154+
permissionMode: options.permissionMode,
155+
});
156+
const profile = modeToProfile(options.mode);
157+
yield* runtime.setSessionProfile(
158+
normalizedCwd,
159+
created.sessionId,
160+
profile,
161+
options.permissionMode
162+
);
163+
sessionId = created.sessionId;
164+
}
165+
const state = yield* session.load(normalizedCwd, sessionId);
166+
if (state.activeProfile) {
167+
yield* runtime.restoreSessionProfile(
168+
normalizedCwd,
169+
state.sessionId,
170+
state.activeProfile,
171+
state.permissionMode
172+
);
173+
}
147174
state.memorySnapshot = memory.loadMemoryForPrompt(state.cwd);
148175
const sid = state.sessionId;
149176

@@ -160,9 +187,7 @@ export const sendMessage = (
160187
}
161188
}
162189
const effectiveMaxSteps = profile?.maxSteps;
163-
const effectiveApproval: any = profile?.readonly
164-
? { permissionMode: 'bypass' }
165-
: options?.approvalOverride;
190+
const effectiveApproval: any = options?.approvalOverride;
166191

167192
if (profile?.hooks?.length) {
168193
yield* hooks.attachSessionHooks(sid, profile.hooks);
@@ -187,6 +212,7 @@ export const sendMessage = (
187212
const stream = agent.runStream({
188213
state,
189214
llm: activeLlm,
215+
profile,
190216
toolPolicy: policy,
191217
maxStepsOverride: effectiveMaxSteps,
192218
approvalOverride: effectiveApproval,
@@ -221,6 +247,7 @@ export function agentLoop(
221247
> {
222248
const state = opts.state;
223249
const llm = opts.llm;
250+
const profile = opts.profile;
224251
const sessionId = state.sessionId;
225252
const projectPath = state.cwd;
226253

@@ -234,9 +261,12 @@ export function agentLoop(
234261
const { skillInstruction, systemPromptVariant, rulesText } = opts;
235262

236263
const allAgentProfiles = runtime.listAgentProfiles(projectPath);
237-
const agentProfiles = resolveSubagentEnabled(projectPath)
264+
const enabledAgentProfiles = resolveSubagentEnabled(projectPath)
238265
? allAgentProfiles.filter((p) => !resolveAgentDisabled(projectPath, p.name))
239266
: [];
267+
const visibleAgentProfiles = isPlanProfile(profile)
268+
? enabledAgentProfiles.filter((p) => p.name === 'explore')
269+
: enabledAgentProfiles;
240270
const basePrompt =
241271
opts.systemOverride ??
242272
buildSystemPrompt({
@@ -245,8 +275,9 @@ export function agentLoop(
245275
shell: process.env.SHELL || process.env.ComSpec || 'bash',
246276
variant: systemPromptVariant ?? 'default',
247277
skillInstruction,
248-
agentProfiles,
278+
agentProfiles: visibleAgentProfiles,
249279
rules: rulesText,
280+
profileSystemPrompt: profile?.systemPrompt,
250281
});
251282

252283
const memoryBlock = state.memorySnapshot;
@@ -260,10 +291,11 @@ export function agentLoop(
260291
const effectiveMaxStopContinuations = opts.maxStopContinuations ?? maxStopContinuations;
261292

262293
let messages: Message[] = [];
294+
let submittedPlanTitle: string | null = null;
263295

264296
for (let attempt = 0; attempt <= maxOverflowRetries; attempt++) {
265297
const payload = yield* Effect.sync(() =>
266-
context.assemblePayload(state.sessionId, state.projectPath, llm.modelInfo.maxTokens)
298+
context.assemblePayload(state.transcriptPath, llm.modelInfo.maxTokens)
267299
);
268300
messages = payload.messages;
269301

@@ -281,6 +313,7 @@ export function agentLoop(
281313
let allToolDefs: ToolDefinition[] = [...builtinTools, ...(opts.mcpTools ?? [])];
282314
if (opts.dispatchTool && resolveSubagentEnabled(projectPath))
283315
allToolDefs = [...allToolDefs, opts.dispatchTool];
316+
if (isPlanProfile(profile)) allToolDefs = [...allToolDefs, submitPlanTool];
284317

285318
const allowedByPolicy = opts.toolPolicy?.allowedTools;
286319
let filteredDefs = allToolDefs;
@@ -303,8 +336,7 @@ export function agentLoop(
303336
const compressResult = yield* Effect.tryPromise({
304337
try: () =>
305338
context.compactIfNeeded(
306-
state.sessionId,
307-
state.projectPath,
339+
state.transcriptPath,
308340
messages,
309341
llm.modelInfo.maxTokens,
310342
llm
@@ -354,8 +386,7 @@ export function agentLoop(
354386
const compressResult = yield* Effect.tryPromise({
355387
try: () =>
356388
context.compactWithLLM(
357-
state.sessionId,
358-
state.projectPath,
389+
state.transcriptPath,
359390
llm.modelInfo.maxTokens,
360391
llm,
361392
undefined
@@ -438,6 +469,15 @@ export function agentLoop(
438469
continue;
439470
}
440471

472+
if (submittedPlanTitle !== null) {
473+
yield* hooks.emit('plan.ready', {
474+
sessionId,
475+
projectPath,
476+
title: submittedPlanTitle,
477+
});
478+
submittedPlanTitle = null;
479+
}
480+
441481
yield* q.offer({ _tag: 'Done', content: resp.content });
442482
lastResult = Result.ok(resp.content);
443483
yield* hooks.emit('agent.turn.end', {
@@ -496,6 +536,14 @@ export function agentLoop(
496536
todoPrinted = true;
497537
}
498538
}
539+
540+
const submitPlanCall = toolCalls?.find((tc) => tc.name === 'submit_plan');
541+
const submitPlanResult = allResults.find(
542+
(r) => r.name === 'submit_plan' && r.type === 'ok'
543+
);
544+
if (submitPlanCall && submitPlanResult && submittedPlanTitle === null) {
545+
submittedPlanTitle = String(submitPlanCall.arguments?.title ?? '');
546+
}
499547
}
500548

501549
if (overflow) continue;
@@ -530,18 +578,19 @@ export function agentLoop(
530578
}).pipe(
531579
Effect.interruptible,
532580
Effect.onInterrupt(() =>
533-
Effect.sync(() => {
534-
Effect.runSync(
535-
q.offer({ _tag: 'Error', error: new AgentError('AGENT_ABORTED', 'cancelled') })
536-
);
537-
hooks
581+
Effect.gen(function* () {
582+
yield* Effect.sync(() => {
583+
Effect.runSync(
584+
q.offer({ _tag: 'Error', error: new AgentError('AGENT_ABORTED', 'cancelled') })
585+
);
586+
});
587+
yield* hooks
538588
.emit('agent.turn.end', {
539589
sessionId,
540590
turnId: state.currentTurnId,
541591
status: 'aborted',
542592
})
543-
.pipe(Effect.runPromise)
544-
.catch(() => {});
593+
.pipe(Effect.ignore);
545594
})
546595
),
547596
Effect.ensuring(

0 commit comments

Comments
 (0)