Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Agent Loop 已闭环:用户发文本、装上下文、调模型、产出文字
- 模型由 `pkg/agent` 在 `Stream` / `CompactIfNeeded` 内按 `ModelConfig` 创建:`provider=fake` 走脚本化假模型(测试用),`provider=openai` 走 OpenAI 兼容 HTTP。不由 Runtime 注入模型实例。
- Handler 直接使用 `*sqlite.Queries` 做 CRUD、SSE 回放、Run 的 Start / Continue / Cancel 和审批裁决;领取后的 Loop 才进入 `internal/agent`。
- `internal/agent` 负责把 pkg 的计算结果持久化为 Run、Turn、消息、用量和事件。`AgentEvent` 必须先同事务写入并递增 `sessions.last_event_seq`,提交后再 `events.Bus.Publish`。
- 工具定义都在 `internal/agent/tools`(`ping`、记忆、编码八工具、`plan_*`)。外部模块只实现 `Ports` 上的接口,由 `cmd/server` 在初始化时注入。Agent 绑定 `Profile.Tools.Names`;运行模式提供 `read` / `write` / `memory`,须覆盖工具全部能力才可调用。记忆工具声明 `memory`。写类编码工具声明 `write` 并默认要审批。目录外路径在 Inspect / jail 失败,不能当成功执行。`plan_*` 只碰 `.cursor/*.md`。审批由工具声明,模式决定是否暂停。一次模型回复的待批 Tool 合成一条审批,一次提交审完再流转;拒绝或单个工具失败不打死 Run。Git 用户操作仍走 HTTP + `pkg/git`。
- 工具定义都在 `internal/agent/tools`(`ping`、记忆、编码八工具、`plan_*`)。外部模块只实现 `Ports` 上的接口,由 `cmd/server` 在初始化时注入。三个内置 Agent(ask / plan / agent)各自一份 `Profile`:底座 system 与发给模型的工具表共用全量注册表,`mode` 冻进 Run 的是可执行 `Names` 与一条 developer 模式规则(发给网关时紧跟底座 system)。工作区在创建 Session 时冻结到 `workspace_id`(用户指定的已存在目录;未指定则 `GIT_REPO` / cwd。指定了但不存在则创建失败);本会话权限只覆盖该目录,目录外的文件操作必须审批(Agent 表和 yolo 都不能直接放行)。审批是流水线:工具默认+参数校验 → 本 Agent `Names`(未绑定 deny,yolo 不能抬)→ Agent `Effects` 表 → `approval`(manual / auto / yolo);每层只审上一层的 `ask`,任一层 `deny` 则不可调用。`auto` 开单后由独立复审模型填单,失败则升级给人。一次模型回复的待批 Tool 合成一条审批;拒绝或单个工具失败不打死 Run。Git 用户操作仍走 HTTP + `pkg/git`,不经过 Agent Tool
- `internal/agent/memory` 负责 TextMemory 的 Get / Upsert / Delete / List、`SearchMessages` 和 `IndexMessage`;用户侧只看/删目录与专题。不负责 Prompt / Context Packet / 对话压缩,不自动建专题,不定义 Tool。Loop 在新 Session / 对话压缩后装冻结目录;写 message 时 `IndexMessage`。超限目录由 Runtime 后台 `CompactIndex` 改短盖写,不改当前 Session 冻结前缀。
- 不要使用 Store 接口包装 sqlc。
- Agent 契约不得依赖 React、UI 包或路由框架。
Expand Down
20 changes: 10 additions & 10 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

本文档定义 CodeDock 当前的技术骨架。目录按能力拆分;Issue、Task、Review、Workspace 等业务目录不属于本项目的基础结构。

Agent Loop 已闭环:Handler 写用户消息与 Run,Worker 领取后由 Runtime 装上下文、调模型、执行 Tool,事件先落库再经 Bus 由 SSE 消费。默认注册 `ping`、记忆工具、编码八工具与 `plan_*`
Agent Loop 已闭环:Handler 写用户消息与 Run,Worker 领取后由 Runtime 装上下文、调模型、执行 Tool,事件先落库再经 Bus 由 SSE 消费。三个内置 Agent(ask / plan / agent)共用同一 Loop;审批走工具 → Agent 表 → 审批模式流水线

## 总体架构

Expand Down Expand Up @@ -61,7 +61,7 @@ CodeDock/
│ │ ├── handler/ # 大部分 HTTP:CRUD、SSE、Start / Continue / Cancel、记忆查看/删除、Git
│ │ ├── agent/ # 运行时编排 + sqlc 持久化
│ │ │ ├── memory/ # 热层目录+专题,冷层工作区 FTS 索引
│ │ │ └── tools/ # 具体工具定义:ping、memory_*
│ │ │ └── tools/ # 具体工具定义:ping、memory_*、编码八工具、plan_*
│ │ ├── events/ # 进程内事件总线
│ │ ├── config/
│ │ ├── logger/
Expand Down Expand Up @@ -137,7 +137,7 @@ packages/views
-> packages/ui
不 import next/*
按业务域拆目录,与 core 对齐(现有 chat)
AgentProvider 在包根注入 client + userId,以及可选的本机目录列举;导航用回调
AgentProvider 在包根注入 client + userId;导航用回调

apps/web
-> packages/views
Expand Down Expand Up @@ -189,11 +189,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi

工具定义全部在本包。Runtime `New` 接收 `Ports`(Execute 要调用的外部实现),再 `Register`:

- 本包写工具名、入参/出参、schema、权限和编排。`ping`,`memory_read` / `memory_write` / `memory_search`,编码八工具(`read` / `write` / `edit` / `ls` / `grep` / `find` / `bash` / `powershell`),以及 `plan_list` / `plan_read` / `plan_write`
- Execute 若依赖外部能力,只通过 `Ports` 上的接口调用;由 `cmd/server` 在初始化时注入具体实现。`Ports.WorkspaceRoot` 只是进程回落,会话级根走 `tool.Input.WorkspaceRoot`
- 编码工具经 jail 限制在会话工作目录;目录外路径 Inspect 失败。`plan_*` 只读写 `.cursor/*.md`
- 本包写工具名、入参/出参、schema、默认 Effect 和编排。`ping`、记忆三件、编码八工具(`read`/`write`/`edit`/`ls`/`grep`/`find`/`bash`/`powershell`)`plan_list`/`plan_read`/`plan_write`
- Execute 与第 1 层路径/计划名校验只通过 `Ports`(可替换 FS/RunCommand)和会话已冻结的工作目录。本包不负责解析或冻结 `workspace_id`,只消费 Handler 写入的路径(或 `Ports.WorkspaceRoot` 进程回落)。目录内走工具默认 Effect;目录外必须审批,Agent 表和 yolo 都不能抬成 allow。批过后才能在目录外执行
- Git 用户操作仍走 HTTP + `pkg/git`,不在本包实现 Git Tool

每个工具只定义入参/出参结构体;执行用 `encoding/json`,给模型的 schema 由 `jsonschema.For` 从类型推断。Agent 通过 `Profile.Tools.Names` 绑定工具。运行模式提供 `read` / `write` / `memory` 能力,只有模式覆盖了工具声明的全部能力时该工具才对模型可见且可 Dispatch。记忆工具声明 `memory`。写类编码工具声明 `write`。审批仍由工具声明 `RequiresApproval` 或 `Effect=ask`,`ask_for_approval` 暂停、`auto_approve` / `yolo` 自动过。一批待批工具对应一条审批,一次提交审完再流转。不 import 父包 `internal/agent`。测试用 Tool 可留在测试文件。
每个工具只定义入参/出参结构体;执行用 `encoding/json`,给模型的 schema 由 `jsonschema.For` 从类型推断。发给模型的是注册表全量工具;`Profile.Tools.Names` 是可执行绑定。模式规则由 `Build` 注入一条 developer 消息;发给网关时紧跟底座 system,不改底座正文。审批流水线:工具默认+参数校验 → 本 Agent `Names`(未绑定 deny,yolo / 已批准都不能抬)→ Agent `Effects` → `approval`(manual/auto/yolo);每层只审上一层的 `ask`。一批待批工具对应一条审批,一次提交审完再流转。不 import 父包 `internal/agent`。测试用 Tool 可留在测试文件。

### `pkg/git`

Expand Down Expand Up @@ -236,11 +236,11 @@ Handler 直接依赖 `*sqlite.Queries`,不经过 Store 接口。Git 带 `sessi

### `packages/views`

组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage`、侧栏、瀑布、审批、prompt、新对话目录选择。包根 `provider.tsx` 注入 `AgentClient` + `userId`,以及可选的本机目录列举。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,请求带当前会话 `session_id`,不进 `AgentContext`。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。
组合 core + ui。按业务域拆,与 core 对齐,不要 `src/`。现有 `chat/`:`ChatPage`、侧栏、瀑布、审批、prompt。包根 `provider.tsx` 注入 `AgentClient` + `userId`。`ChatPage` 接 `sessionId` 与 `onOpenSession`。Git 在 `git/`:`GitProvider` 只注入 `GitClient`,不进 `AgentContext`。不 import `next/*`。新业务新建目录,不预建 Issue / Task / Review / Workspace。

### `apps/web`

路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient`、包 `AgentProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。新对话选目录走 web 的本机目录接口。Git 页在 `(chat)` 组外的 `/git`,只装配 `GitClient`,并带当前会话 `session_id`。开发态顶栏(对话 / 仓库)只放 web,views 不知道路径。
路由、`NEXT_PUBLIC_API_BASE` / `NEXT_PUBLIC_USER_ID`、创建 `AgentClient`、包 `AgentProvider`、`router.push`。本机 Web 直连 `:8080`(仅回环 Origin 的 CORS)。Git 页在 `(chat)` 组外的 `/git`,只装配 `GitClient`。开发态顶栏(对话 / 仓库)只放 web,views 不知道路径。

## 组装关系

Expand All @@ -257,7 +257,7 @@ Worker
-> pkg.Build
-> pkg.Stream # 按 ModelConfig 在 pkg 内创建 fake 或 openai
-> Transition:先落库再 Bus
-> pkg.Dispatch # ping、memory_*、编码八工具、plan_* 及测试用 Tool
-> pkg.Dispatch # 经流水线后执行已绑定工具
```

## 配置
Expand Down
4 changes: 2 additions & 2 deletions packages/core/chat/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type {
AgentEvent,
AgentMode,
Approval,
CreateSessionRequest,
DecideApprovalRequest,
Expand Down Expand Up @@ -120,7 +119,8 @@ export class AgentClient {
method: "POST",
json: {
content: req.content,
mode: req.mode ?? ("ask_for_approval" satisfies AgentMode),
mode: req.mode ?? "agent",
approval: req.approval ?? "manual",
},
});
}
Expand Down
4 changes: 3 additions & 1 deletion packages/core/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
applyApprovals,
applyEvent,
decisionsForApproval,
applyLocalCancel,
applyOptimisticUser,
applyUserText,
dropOptimisticUser,
Expand All @@ -25,7 +26,8 @@ export {
export { parseSSEBlock, parseSSEChunk, watchEvents, type WatchEventsOptions } from "./sse.ts";
export type {
AgentEvent,
AgentMode,
ApprovalMode,
WorkMode,
Approval,
ApprovalDecision,
ApprovalStatus,
Expand Down
68 changes: 62 additions & 6 deletions packages/core/chat/reducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
applyApprovalRecord,
applyApprovals,
applyEvent,
applyLocalCancel,
applyOptimisticUser,
decisionsForApproval,
emptyState,
Expand Down Expand Up @@ -46,7 +47,7 @@ test("hydrate fills user text from messages and folds events in order", () => {
ev({
seq: 2,
type: "run.created",
payload: { trigger_message_id: "m-user", mode: "ask_for_approval", status: "queued" },
payload: { trigger_message_id: "m-user", mode: "agent", status: "queued" },
}),
ev({
seq: 3,
Expand Down Expand Up @@ -213,7 +214,7 @@ test("run.created before optimistic keeps a single user bubble with text", () =>
ev({
seq: 1,
type: "run.created",
payload: { trigger_message_id: "m-real", mode: "auto_approve", status: "queued" },
payload: { trigger_message_id: "m-real", mode: "agent", status: "queued" },
}),
);
state = applyOptimisticUser(state, { runId: "r1", text: "hi" });
Expand All @@ -229,7 +230,7 @@ test("run.created after local optimistic uses pending text", () => {
ev({
seq: 1,
type: "run.created",
payload: { trigger_message_id: "m-real", mode: "auto_approve", status: "queued" },
payload: { trigger_message_id: "m-real", mode: "agent", status: "queued" },
}),
);
const users = state.items.filter((item) => item.kind === "user");
Expand Down Expand Up @@ -452,6 +453,61 @@ test("denied approval marks the tool denied", () => {
assert.equal(tool.state, "denied");
});

test("optimistic user is not marked queued while another run is still local", () => {
let state = applyEvent(
emptyState(),
ev({
seq: 1,
run_id: "r1",
type: "run.created",
payload: { trigger_message_id: "m1", mode: "agent", status: "queued", text: "first" },
}),
);
state = applyEvent(
state,
ev({
seq: 2,
run_id: "r1",
type: "run.state_changed",
payload: { from: "queued", to: "running_llm", reason: "" },
}),
);
state = applyOptimisticUser(state, { runId: "local-2", text: "immediate" });
const optimistic = state.items.find(
(item) => item.kind === "user" && item.messageId === "pending:local-2",
);
assert.ok(optimistic && optimistic.kind === "user");
assert.equal(optimistic.queued, false);
});

test("applyLocalCancel clears the executing run", () => {
let state = applyEvent(
emptyState(),
ev({
seq: 1,
run_id: "r1",
type: "run.created",
payload: { trigger_message_id: "m1", mode: "agent", status: "queued", text: "first" },
}),
);
state = applyEvent(
state,
ev({
seq: 2,
run_id: "r1",
type: "run.state_changed",
payload: { from: "queued", to: "running_llm", reason: "" },
}),
);
state = applyLocalCancel(state, "r1");
assert.equal(state.activeRunId, null);
assert.equal(state.runStatus, "cancelled");
state = applyOptimisticUser(state, { runId: "local-2", text: "next" });
const optimistic = state.items.find((item) => item.kind === "user" && item.text === "next");
assert.ok(optimistic && optimistic.kind === "user");
assert.equal(optimistic.queued, false);
});

test("optimistic user is replaced when run.created arrives", () => {
let state = applyOptimisticUser(emptyState(), { runId: "r1", text: "hi" });
assert.equal(state.items[0]?.kind === "user" && state.items[0].messageId, "pending:r1");
Expand All @@ -460,7 +516,7 @@ test("optimistic user is replaced when run.created arrives", () => {
ev({
seq: 4,
type: "run.created",
payload: { trigger_message_id: "m-real", mode: "auto_approve", status: "queued" },
payload: { trigger_message_id: "m-real", mode: "agent", status: "queued" },
}),
);
const users = state.items.filter((item) => item.kind === "user");
Expand All @@ -476,7 +532,7 @@ test("a later run.created does not steal the executing run", () => {
seq: 1,
run_id: "r1",
type: "run.created",
payload: { trigger_message_id: "m1", mode: "auto_approve", status: "queued", text: "first" },
payload: { trigger_message_id: "m1", mode: "agent", status: "queued", text: "first" },
}),
);
state = applyEvent(
Expand All @@ -494,7 +550,7 @@ test("a later run.created does not steal the executing run", () => {
seq: 3,
run_id: "r2",
type: "run.created",
payload: { trigger_message_id: "m2", mode: "auto_approve", status: "queued", text: "second" },
payload: { trigger_message_id: "m2", mode: "agent", status: "queued", text: "second" },
}),
);
assert.equal(state.activeRunId, "r1");
Expand Down
15 changes: 13 additions & 2 deletions packages/core/chat/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,27 @@ export function applyOptimisticUser(
state: SessionState,
input: { runId: string; text: string },
): SessionState {
const queued = hasExecutingRun(state, input.runId);
return upsertUser(state, {
messageId: `pending:${input.runId}`,
runId: input.runId,
text: input.text,
queued,
queued: false,
seq: state.lastSeq,
});
}

/** 本地已发出取消后,先清掉执行中标记,避免新气泡被当成排队。 */
export function applyLocalCancel(state: SessionState, runId: string): SessionState {
if (!runId || state.activeRunId !== runId) {
return state;
}
return {
...state,
activeRunId: null,
runStatus: "cancelled",
};
}

export function dropOptimisticUser(state: SessionState, runId: string): SessionState {
return removeItem(state, userId(`pending:${runId}`));
}
Expand Down
18 changes: 10 additions & 8 deletions packages/core/chat/types.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
export type SessionStatus = "active" | "archived";

export type AgentMode =
| "ask_for_approval"
| "auto_approve"
| "yolo"
| "ask"
| "plan";
export type WorkMode = "ask" | "plan" | "agent";

export type ApprovalMode = "manual" | "auto" | "yolo";

export type RunStatus =
| "queued"
Expand Down Expand Up @@ -58,6 +55,7 @@ export interface Session {
tenant_id: string;
user_id: string;
agent_id: string;
/** 创建会话时冻结的工作目录(绝对路径);本会话权限只覆盖该目录。 */
workspace_id: string;
status: SessionStatus;
active_run_id?: string;
Expand Down Expand Up @@ -132,7 +130,8 @@ export interface AgentEvent<T = unknown> {

export interface RunCreatedPayload {
trigger_message_id: string;
mode: AgentMode;
mode: WorkMode;
approval?: ApprovalMode;
status: RunStatus;
text?: string;
}
Expand Down Expand Up @@ -212,6 +211,8 @@ export interface Run {
id: string;
session_id: string;
status: RunStatus;
mode?: WorkMode;
approval?: ApprovalMode;
cancel_requested?: boolean;
needs_recover?: boolean;
}
Expand All @@ -230,7 +231,8 @@ export function isRecoverableRun(status: string): boolean {

export interface StartRunRequest {
content: string;
mode?: AgentMode;
mode?: WorkMode;
approval?: ApprovalMode;
}

export interface StartRunResponse {
Expand Down
4 changes: 3 additions & 1 deletion packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
applyApprovals,
applyEvent,
decisionsForApproval,
applyLocalCancel,
applyOptimisticUser,
applyUserText,
decodeText,
Expand Down Expand Up @@ -53,7 +54,8 @@ export type {
} from "./git/index.ts";
export type {
AgentEvent,
AgentMode,
ApprovalMode,
WorkMode,
Approval,
ApprovalDecision,
ApprovalStatus,
Expand Down
17 changes: 14 additions & 3 deletions packages/views/chat/approval-dock.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
"use client";

import type { ApprovalDecision, ApprovalStatus, ApprovalToolCall, TimelineItem } from "@codedock/core/chat";
import { Button, cn, formatJSON } from "@codedock/ui";
import {
planPreviewFromTool,
type ApprovalDecision,
type ApprovalStatus,
type ApprovalToolCall,
type TimelineItem,
} from "@codedock/core/chat";
import { Button, cn, formatJSON, MessageResponse } from "@codedock/ui";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useMemo, useState } from "react";

Expand Down Expand Up @@ -63,6 +69,7 @@ export function ApprovalDock({
}
const choiceKey = pageKey(page);
const currentChoice = choiceOf(page, choices);
const planPreview = planPreviewFromTool(page.call);

const go = (next: number) => {
if (next < 0 || next >= pages.length) {
Expand Down Expand Up @@ -160,7 +167,11 @@ export function ApprovalDock({
<div className="font-mono text-[11px] leading-4 text-foreground">{page.call.name || "工具调用"}</div>
<div className={cn("text-[10px] leading-3.5", statusTone(currentChoice))}>{statusLabel(currentChoice)}</div>
</div>
{page.call.arguments != null ? (
{planPreview?.content ? (
<div className="mt-1 max-h-48 overflow-auto">
<MessageResponse className="text-[13px]">{planPreview.content}</MessageResponse>
</div>
) : page.call.arguments != null ? (
<pre className="mt-0.5 max-h-16 overflow-auto font-mono text-[11px] leading-4 text-muted-foreground">
{formatJSON(page.call.arguments)}
</pre>
Expand Down
Loading
Loading