Skip to content

Commit 4b57b5e

Browse files
authored
Merge pull request #504 from Cai-Tang-www/feat/issue-490-user-builtin-hooks-p2
feat(runtime): P2 全局 User Builtin Hooks 接入与安全裁剪(#490
2 parents 4d168b0 + c337f85 commit 4b57b5e

26 files changed

Lines changed: 2421 additions & 76 deletions

docs/guides/configuration.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@ runtime:
2929
max_no_progress_streak: 5
3030
max_repeat_cycle_streak: 3
3131
max_turns: 90
32+
hooks:
33+
enabled: true
34+
user_hooks_enabled: true
35+
default_timeout_sec: 2
36+
default_failure_policy: warn_only
37+
items:
38+
- id: warn-bash
39+
enabled: true
40+
point: before_tool_call
41+
scope: user
42+
kind: builtin
43+
mode: sync
44+
handler: warn_on_tool_call
45+
priority: 100
46+
params:
47+
tool_name: bash
48+
message: "bash tool is invoked"
3249
assets:
3350
max_session_asset_bytes: 20971520
3451
max_session_assets_total_bytes: 20971520
@@ -94,9 +111,32 @@ context:
94111
| `runtime.max_no_progress_streak` | 连续“无进展”轮次提醒阈值,默认 `5`;达到 `limit-1` 起会向模型注入纠偏提示,不会直接终止运行 |
95112
| `runtime.max_repeat_cycle_streak` | 连续“重复调用同一工具参数”提醒阈值,默认 `3`;达到阈值后触发重复循环提醒,不会直接终止运行 |
96113
| `runtime.max_turns` | 单次 Run 的最大推理轮数上限,默认 `40`;达到上限后直接终止并返回明确 stop reason |
114+
| `runtime.hooks.enabled` | hooks 总开关;关闭后不执行 runtime hooks |
115+
| `runtime.hooks.user_hooks_enabled` | user hooks 开关;关闭后不加载 `runtime.hooks.items` |
116+
| `runtime.hooks.default_timeout_sec` | user hook 默认超时秒数,需 `> 0` |
117+
| `runtime.hooks.default_failure_policy` | 默认失败策略,支持 `warn_only` / `fail_open` / `fail_closed` |
118+
| `runtime.hooks.items` | user builtin hooks 列表;仅支持 `scope=user`、`kind=builtin`、`mode=sync` |
97119
| `runtime.assets.max_session_asset_bytes` | 单个 `session_asset` 最大原始字节数,默认 `20971520`(20 MiB);`0` 或未配置时回退默认值 |
98120
| `runtime.assets.max_session_assets_total_bytes` | 单次请求可携带的 `session_asset` 原始总字节上限,默认 `20971520`(20 MiB);`0` 或未配置时回退默认值 |
99121

122+
### `runtime.hooks.items` 字段约束
123+
124+
| 字段 | 说明 |
125+
|------|------|
126+
| `id` | hook 唯一标识,同一配置文件内不可重复 |
127+
| `enabled` | 是否启用该 hook,默认 `true` |
128+
| `point` | 仅支持 `before_tool_call` / `after_tool_result` / `before_completion_decision` |
129+
| `scope` | P2 固定为 `user` |
130+
| `kind` | P2 固定为 `builtin` |
131+
| `mode` | P2 固定为 `sync` |
132+
| `handler` | 仅支持 `require_file_exists` / `warn_on_tool_call` / `add_context_note` |
133+
| `priority` | 同一 hook point 内执行优先级,数值越大越先执行 |
134+
| `timeout_sec` | 覆盖默认超时;未配置时继承 `runtime.hooks.default_timeout_sec` |
135+
| `failure_policy` | 覆盖默认失败策略;未配置时继承 `runtime.hooks.default_failure_policy` |
136+
| `params` | handler 参数;不同 handler 使用不同键 |
137+
138+
> 注意:`warn_only` 在 runtime 内部映射为 `fail_open`,表示记录失败但不阻断主链。
139+
100140
## Budget 解析规则
101141

102142
NeoCode 已不再使用旧的 `auto_compact` 阈值语义,当前统一使用 `context.budget`:

docs/runtime-hooks-design.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# Runtime Hooks 设计说明
2+
3+
本文记录 NeoCode runtime hooks 的当前实现边界与约束,确保配置、运行时行为与可观测性一致。
4+
5+
## 当前阶段
6+
7+
当前已实现能力:
8+
9+
- P0:hooks core(registry / executor / timeout / panic recover / failure policy / hook events)
10+
- P1:接入 `before_tool_call``after_tool_result``before_completion_decision`
11+
- P2:全局 user builtin hooks(`runtime.hooks`
12+
13+
当前未实现能力:
14+
15+
- repo hooks(P3)
16+
- async / async_rewake(P5)
17+
- command/http/prompt/agent hooks(P6)
18+
19+
## P2 user hooks 边界
20+
21+
P2 仅支持:
22+
23+
- `scope=user`
24+
- `kind=builtin`
25+
- `mode=sync`
26+
- 挂载点:`before_tool_call``after_tool_result``before_completion_decision`
27+
- handler:`require_file_exists``warn_on_tool_call``add_context_note`
28+
29+
P2 明确不支持:
30+
31+
- user hook 修改 tool 输入或 tool result
32+
- user hook 直接写入 provider-facing prompt
33+
34+
## 安全模型
35+
36+
### 上下文裁剪
37+
38+
user hook 接收的 `HookContext` 经过白名单裁剪,仅保留最小必要字段:
39+
40+
- `run_id` / `session_id`
41+
- `point` / `tool_call_id` / `tool_name`
42+
- `is_error` / `error_class`
43+
- `result_content_preview` / `result_metadata_present`
44+
- `execution_error`
45+
- `workdir`
46+
47+
不会暴露:
48+
49+
- API key / capability token
50+
- service 指针与 provider 客户端对象
51+
- 原始工具参数明文
52+
53+
### 路径约束
54+
55+
`require_file_exists``params.path` 强制执行工作目录边界检查:
56+
57+
- 相对路径按当前运行 workdir 解析
58+
- 绝对路径必须位于 workdir 内
59+
- symlink 路径会进行 realpath 校验,禁止绕过
60+
61+
## 可观测性
62+
63+
runtime 会透传 hooks 生命周期事件:
64+
65+
- `hook_started`
66+
- `hook_finished`
67+
- `hook_failed`
68+
- `hook_blocked`
69+
70+
`hook_finished/hook_failed` 包含 `message` 字段,用于承载 warning/note 文本。
71+
user hook 的 `message` 同时进入 runtime 的 annotation buffer(运行态内存缓冲),用于后续观测与诊断。
72+
73+
## 失败策略
74+
75+
配置层支持:
76+
77+
- `warn_only`
78+
- `fail_open`
79+
- `fail_closed`
80+
81+
运行时映射:
82+
83+
- `warn_only` -> `fail_open`
84+
- `fail_open` -> `fail_open`
85+
- `fail_closed` -> `fail_closed`
86+
87+
其中 `warn_only/fail_open` 不阻断主链,仅记录失败;`fail_closed` 触发阻断。

internal/app/bootstrap.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@ func BuildGatewayServerDeps(ctx context.Context, opts BootstrapOptions) (Runtime
212212
return resolution.PromptBudget, string(resolution.Source), nil
213213
},
214214
))
215+
if err := agentruntime.ConfigureRuntimeHooks(runtimeSvc, cfg); err != nil {
216+
return RuntimeBundle{}, err
217+
}
215218

216219
// 注入记忆提取钩子:当 AutoExtract 启用且 memoSvc 可用时,ReAct 循环完成后异步提取记忆。
217220
if memoSvc != nil && cfg.Memo.AutoExtract {

internal/config/loader_test.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,80 @@ func TestLoaderLoadMalformedYAML(t *testing.T) {
101101
}
102102
}
103103

104+
func TestLoaderLoadRuntimeHooksConfig(t *testing.T) {
105+
t.Parallel()
106+
107+
loader := NewLoader(t.TempDir(), testDefaultConfig())
108+
raw := `
109+
selected_provider: openai
110+
current_model: gpt-4.1
111+
shell: powershell
112+
runtime:
113+
hooks:
114+
enabled: true
115+
user_hooks_enabled: true
116+
default_timeout_sec: 3
117+
default_failure_policy: warn_only
118+
items:
119+
- id: warn-bash
120+
enabled: true
121+
point: before_tool_call
122+
scope: user
123+
kind: builtin
124+
mode: sync
125+
handler: warn_on_tool_call
126+
priority: 100
127+
timeout_sec: 2
128+
failure_policy: warn_only
129+
params:
130+
tool_name: bash
131+
message: "bash is called"
132+
`
133+
writeLoaderConfig(t, loader, raw)
134+
cfg, err := loader.Load(context.Background())
135+
if err != nil {
136+
t.Fatalf("Load() error = %v", err)
137+
}
138+
if cfg == nil {
139+
t.Fatal("cfg is nil")
140+
}
141+
if !cfg.Runtime.Hooks.IsEnabled() || !cfg.Runtime.Hooks.IsUserHooksEnabled() {
142+
t.Fatalf("unexpected hook switches: %+v", cfg.Runtime.Hooks)
143+
}
144+
if len(cfg.Runtime.Hooks.Items) != 1 {
145+
t.Fatalf("len(items)=%d, want 1", len(cfg.Runtime.Hooks.Items))
146+
}
147+
item := cfg.Runtime.Hooks.Items[0]
148+
if item.Handler != "warn_on_tool_call" {
149+
t.Fatalf("handler=%q, want warn_on_tool_call", item.Handler)
150+
}
151+
}
152+
153+
func TestLoaderRejectsUnsupportedRuntimeHookHandler(t *testing.T) {
154+
t.Parallel()
155+
156+
loader := NewLoader(t.TempDir(), testDefaultConfig())
157+
raw := `
158+
selected_provider: openai
159+
current_model: gpt-4.1
160+
shell: powershell
161+
runtime:
162+
hooks:
163+
items:
164+
- id: invalid-handler
165+
point: before_tool_call
166+
scope: user
167+
kind: builtin
168+
mode: sync
169+
handler: shell_exec
170+
`
171+
writeLoaderConfig(t, loader, raw)
172+
_, err := loader.Load(context.Background())
173+
if err == nil || !strings.Contains(err.Error(), "runtime.hooks.items[0]") {
174+
t.Fatalf("expected runtime hooks validation error, got %v", err)
175+
}
176+
}
177+
104178
func TestLoaderRejectsLegacyWorkdirKey(t *testing.T) {
105179
t.Parallel()
106180

internal/config/runtime.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type RuntimeConfig struct {
1919
MaxRepeatCycleStreak int `yaml:"max_repeat_cycle_streak,omitempty"`
2020
MaxTurns int `yaml:"max_turns,omitempty"`
2121
Verification VerificationConfig `yaml:"verification,omitempty"`
22+
Hooks RuntimeHooksConfig `yaml:"hooks,omitempty"`
2223
Assets RuntimeAssetsConfig `yaml:"assets,omitempty"`
2324
}
2425

@@ -35,6 +36,7 @@ func defaultRuntimeConfig() RuntimeConfig {
3536
MaxRepeatCycleStreak: DefaultMaxRepeatCycleStreak,
3637
MaxTurns: DefaultMaxTurns,
3738
Verification: defaultVerificationConfig(),
39+
Hooks: defaultRuntimeHooksConfig(),
3840
Assets: defaultRuntimeAssetsConfig(),
3941
}
4042
}
@@ -54,6 +56,7 @@ func (c RuntimeConfig) Clone() RuntimeConfig {
5456
MaxRepeatCycleStreak: c.MaxRepeatCycleStreak,
5557
MaxTurns: c.MaxTurns,
5658
Verification: c.Verification.Clone(),
59+
Hooks: c.Hooks.Clone(),
5760
Assets: c.Assets.Clone(),
5861
}
5962
}
@@ -73,6 +76,7 @@ func (c *RuntimeConfig) ApplyDefaults(defaults RuntimeConfig) {
7376
c.MaxTurns = defaults.MaxTurns
7477
}
7578
c.Verification.ApplyDefaults(defaults.Verification)
79+
c.Hooks.ApplyDefaults(defaults.Hooks)
7680
c.Assets.ApplyDefaults(defaults.Assets)
7781
}
7882

@@ -92,6 +96,11 @@ func (c RuntimeConfig) Validate() error {
9296
if err := verification.Validate(); err != nil {
9397
return err
9498
}
99+
hooks := c.Hooks.Clone()
100+
hooks.ApplyDefaults(defaultRuntimeHooksConfig())
101+
if err := hooks.Validate(); err != nil {
102+
return err
103+
}
95104
if err := c.Assets.Validate(); err != nil {
96105
return err
97106
}

0 commit comments

Comments
 (0)