Skip to content

Commit 24adde7

Browse files
committed
feat: add hook middleware support
1 parent e9448cb commit 24adde7

5 files changed

Lines changed: 246 additions & 4 deletions

File tree

‎packages/core/README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,14 @@ await session.close()
3636
```
3737

3838
如果提供自定义工具/LLM/prompt/sink,可在 deps/options 中覆盖对应字段。默认配置会选择当前 provider 并写入用户目录的 sessions。
39+
40+
## Hook 与中间件
41+
42+
Core 支持在关键阶段注册 Hook,并可通过 middleware 链式扩展:
43+
44+
- `onTurnStart(payload, next?)`:每轮开始时触发,`payload` 包含 `turn` 与用户输入。
45+
- `onAction(payload, next?)`:模型请求调用工具时触发,可用于审计参数或落盘。
46+
- `onObservation(payload, next?)`:工具返回后触发,`payload` 提供 `tool`、`observation`、`step` 等信息。
47+
- `onFinal(payload, next?)`:回合结束时触发,附带最终文本与状态码。
48+
49+
Hook 既可以直接传入回调,也可以传入 `(next, payload) => { ...; await next() }` 形式的中间件数组,满足链路追踪、埋点等需求。所有 Hook 均为可选,未提供时不会影响主流程。
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { beforeAll, afterAll, describe, expect, test } from 'bun:test'
2+
import { join } from 'node:path'
3+
import { tmpdir } from 'node:os'
4+
import { $ } from 'bun'
5+
import { z } from 'zod'
6+
import { createAgentSession, createTokenCounter } from '@memo/core'
7+
import type { AgentSession } from '@memo/core'
8+
9+
let tempHome: string
10+
let previousHome: string | undefined
11+
12+
async function makeTempDir(prefix: string) {
13+
const dir = join(tmpdir(), `${prefix}-${crypto.randomUUID()}`)
14+
await $`mkdir -p ${dir}`
15+
return dir
16+
}
17+
18+
async function removeDir(dir: string) {
19+
await $`rm -rf ${dir}`
20+
}
21+
22+
describe('hook middleware chain', () => {
23+
let session: AgentSession | undefined
24+
25+
beforeAll(async () => {
26+
tempHome = await makeTempDir('memo-core-hooks')
27+
previousHome = process.env.MEMO_HOME
28+
process.env.MEMO_HOME = tempHome
29+
})
30+
31+
afterAll(async () => {
32+
if (session) {
33+
await session.close()
34+
}
35+
if (previousHome === undefined) {
36+
delete process.env.MEMO_HOME
37+
} else {
38+
process.env.MEMO_HOME = previousHome
39+
}
40+
await removeDir(tempHome)
41+
})
42+
43+
test('runs optional hooks in order with middleware', async () => {
44+
const calls: string[] = []
45+
let llmStep = 0
46+
47+
session = await createAgentSession(
48+
{
49+
tools: {
50+
echo: {
51+
name: 'echo',
52+
description: 'echo text',
53+
inputSchema: z.object({ text: z.string() }),
54+
async execute(input) {
55+
return { content: [{ type: 'text', text: `echo:${input.text}` }] } as any
56+
},
57+
},
58+
},
59+
callLLM: async () => {
60+
llmStep += 1
61+
if (llmStep === 1) {
62+
return JSON.stringify({ tool: 'echo', input: { text: 'hello' } })
63+
}
64+
return JSON.stringify({ final: 'done' })
65+
},
66+
historySinks: [],
67+
tokenCounter: createTokenCounter('cl100k_base'),
68+
onTurnStart: [
69+
({ turn, input }) => {
70+
calls.push(`start:${turn}:${input}`)
71+
},
72+
async (next, payload) => {
73+
calls.push('start-mw-before')
74+
await next()
75+
calls.push('start-mw-after')
76+
},
77+
],
78+
onAction: ({ tool, input }) => {
79+
calls.push(`action:${tool}:${JSON.stringify(input)}`)
80+
},
81+
onObservation: [
82+
({ observation }) => {
83+
calls.push(`ob:${observation}`)
84+
},
85+
async (next) => {
86+
calls.push('ob-mw-before')
87+
await next()
88+
calls.push('ob-mw-after')
89+
},
90+
],
91+
onFinal: ({ final, status }) => {
92+
calls.push(`final:${final}:${status}`)
93+
},
94+
},
95+
{ mode: 'once' },
96+
)
97+
98+
const result = await session.runTurn('hi')
99+
expect(result.finalText).toBe('done')
100+
expect(calls).toEqual([
101+
'start:1:hi',
102+
'start-mw-before',
103+
'start-mw-after',
104+
'action:echo:{"text":"hello"}',
105+
'ob:echo:hello',
106+
'ob-mw-before',
107+
'ob-mw-after',
108+
'final:done:ok',
109+
])
110+
})
111+
})

‎packages/core/src/runtime/session.ts‎

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,24 @@ import { withDefaultDeps } from '@memo/core/runtime/defaults'
77
import { parseAssistant } from '@memo/core/utils/utils'
88
import type { CallToolResult } from '@modelcontextprotocol/sdk/types'
99
import type {
10+
ActionPayload,
1011
AgentSession,
1112
AgentSessionDeps,
1213
AgentSessionOptions,
1314
AgentStepTrace,
1415
ChatMessage,
16+
FinalPayload,
1517
HistoryEvent,
1618
HistorySink,
19+
Hook,
20+
HookMiddleware,
1721
LLMResponse,
22+
ObservationPayload,
1823
ParsedAssistant,
1924
SessionMode,
2025
TokenCounter,
2126
TokenUsage,
27+
TurnStartPayload,
2228
TurnResult,
2329
TurnStatus,
2430
} from '@memo/core/types'
@@ -61,6 +67,22 @@ async function emitEventToSinks(event: HistoryEvent, sinks: HistorySink[]) {
6167
}
6268
}
6369

70+
async function runHookChain<TArgs extends any[]>(hook: Hook<TArgs> | undefined, ...args: TArgs) {
71+
const middlewares = normalizeHook(hook, args.length)
72+
if (!middlewares.length) return
73+
let idx = -1
74+
const dispatch = async (i: number): Promise<void> => {
75+
if (i <= idx) {
76+
throw new Error('hook middleware 调用顺序错误:next 被重复触发')
77+
}
78+
idx = i
79+
const fn = middlewares[i]
80+
if (!fn) return
81+
await fn(() => dispatch(i + 1), ...args)
82+
}
83+
await dispatch(0)
84+
}
85+
6486
function flattenCallToolResult(result: CallToolResult): string {
6587
const texts =
6688
result.content?.flatMap((item) => {
@@ -94,6 +116,24 @@ function parseToolInput(tool: ToolRegistry[string], rawInput: unknown) {
94116
return { ok: true as const, data: parsed.data }
95117
}
96118

119+
function normalizeHook<TArgs extends any[]>(
120+
hook: Hook<TArgs> | undefined,
121+
argsLength: number,
122+
): HookMiddleware<TArgs>[] {
123+
if (!hook) return []
124+
const list = Array.isArray(hook) ? hook : [hook]
125+
return list.map((fn) => {
126+
// 若函数签名不包含 next,视为普通回调,执行后自动进入下一个 middleware。
127+
if (fn.length <= argsLength) {
128+
return async (next: () => Promise<void>, ...args: TArgs) => {
129+
await (fn as (...args: TArgs) => unknown)(...args)
130+
await next()
131+
}
132+
}
133+
return fn
134+
})
135+
}
136+
97137
/** 进程内的对话 Session,实现多轮运行与日志写入。 */
98138
class AgentSessionImpl implements AgentSession {
99139
public id: string
@@ -154,6 +194,7 @@ class AgentSessionImpl implements AgentSession {
154194
content: input,
155195
meta: { tokens: { prompt: promptTokens } },
156196
})
197+
await runHookChain<TurnStartPayload>(this.deps.onTurnStart, { turn, input })
157198

158199
let finalText = ''
159200
let status: TurnStatus = 'ok'
@@ -176,6 +217,12 @@ class AgentSessionImpl implements AgentSession {
176217
role: 'assistant',
177218
meta: { tokens: { prompt: estimatedPrompt } },
178219
})
220+
await runHookChain<FinalPayload>(this.deps.onFinal, {
221+
turn,
222+
step,
223+
final: limitMessage,
224+
status,
225+
})
179226
break
180227
}
181228
if (this.options.warnPromptTokens && estimatedPrompt > this.options.warnPromptTokens) {
@@ -201,6 +248,11 @@ class AgentSessionImpl implements AgentSession {
201248
finalText = msg
202249
errorMessage = msg
203250
await this.emitEvent('final', { turn, content: msg, role: 'assistant' })
251+
await runHookChain<FinalPayload>(this.deps.onFinal, {
252+
turn,
253+
final: msg,
254+
status,
255+
})
204256
break
205257
}
206258

@@ -247,10 +299,22 @@ class AgentSessionImpl implements AgentSession {
247299
role: 'assistant',
248300
meta: { tokens: stepUsage },
249301
})
302+
await runHookChain<FinalPayload>(this.deps.onFinal, {
303+
turn,
304+
step,
305+
final: parsed.final,
306+
status,
307+
})
250308
break
251309
}
252310

253311
if (parsed.action) {
312+
await runHookChain<ActionPayload>(this.deps.onAction, {
313+
turn,
314+
step,
315+
tool: parsed.action.tool,
316+
input: parsed.action.input,
317+
})
254318
await this.emitEvent('action', {
255319
turn,
256320
step,
@@ -283,7 +347,12 @@ class AgentSessionImpl implements AgentSession {
283347
if (lastStep) {
284348
lastStep.observation = observation
285349
}
286-
this.deps.onObservation?.(parsed.action.tool, observation, step)
350+
await runHookChain<ObservationPayload>(this.deps.onObservation, {
351+
turn,
352+
step,
353+
tool: parsed.action.tool,
354+
observation,
355+
})
287356

288357
await this.emitEvent('observation', {
289358
turn,
@@ -311,6 +380,11 @@ class AgentSessionImpl implements AgentSession {
311380
content: finalText,
312381
role: 'assistant',
313382
})
383+
await runHookChain<FinalPayload>(this.deps.onFinal, {
384+
turn,
385+
final: finalText,
386+
status,
387+
})
314388
}
315389

316390
await this.emitEvent('turn_end', {

‎packages/core/src/types.ts‎

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,19 @@ export type AgentDeps = {
9595
loadPrompt?: () => Promise<string>
9696
/** 每次 assistant 输出时的回调。 */
9797
onAssistantStep?: (content: string, step: number) => void
98-
/** 每次工具 observation 返回时的回调。 */
99-
onObservation?: (tool: string, observation: string, step: number) => void
98+
/**
99+
* Turn 开始的 Hook,可用于接入中间件(日志、链路追踪等)。
100+
* 支持直接回调或 middleware 链(next -> ...)。
101+
*/
102+
onTurnStart?: Hook<[TurnStartPayload]>
103+
/**
104+
* 模型决定调用工具时的 Hook,可用于审核工具参数、记录链路等。
105+
*/
106+
onAction?: Hook<[ActionPayload]>
107+
/** 每次工具 observation 返回时的 Hook,支持中间件。 */
108+
onObservation?: Hook<[ObservationPayload]>
109+
/** 最终输出时的 Hook,携带状态码,支持中间件。 */
110+
onFinal?: Hook<[FinalPayload]>
100111
/** 资源释放回调(如关闭 MCP Client)。 */
101112
dispose?: () => Promise<void>
102113
}
@@ -191,6 +202,41 @@ export type HistoryEvent = {
191202
meta?: Record<string, unknown>
192203
}
193204

205+
/** Hook middleware:形如 (next, payload...),可通过 next 形成链式调用。 */
206+
export type HookMiddleware<TArgs extends any[]> = (
207+
next: () => Promise<void>,
208+
...args: TArgs
209+
) => void | Promise<void>
210+
211+
/** 普通 Hook 回调。 */
212+
export type HookHandler<TArgs extends any[]> = (...args: TArgs) => void | Promise<void>
213+
214+
/**
215+
* Hook 类型:支持单个回调、单个 middleware,或数组(多中间件)。
216+
* - 若函数签名不包含 next,则视为普通回调,自动串联到 middleware 链中。
217+
*/
218+
export type Hook<TArgs extends any[]> =
219+
| HookHandler<TArgs>
220+
| HookMiddleware<TArgs>
221+
| Array<HookHandler<TArgs> | HookMiddleware<TArgs>>
222+
223+
/** Turn 开始的 Hook 入参。 */
224+
export type TurnStartPayload = { turn: number; input: string }
225+
226+
/** Action Hook 入参。 */
227+
export type ActionPayload = { turn: number; step: number; tool: string; input: unknown }
228+
229+
/** Observation Hook 入参。 */
230+
export type ObservationPayload = {
231+
turn: number
232+
step: number
233+
tool: string
234+
observation: string
235+
}
236+
237+
/** Final Hook 入参。 */
238+
export type FinalPayload = { turn: number; step?: number; final: string; status: TurnStatus }
239+
194240
/** 历史落盘抽象,可输出到文件/外部系统。 */
195241
export type HistorySink = {
196242
/** 写入单条事件。 */

‎packages/ui/src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ async function runInteractive(parsed: ParsedArgs) {
116116
}
117117
process.stdout.write(text)
118118
},
119-
onObservation: (tool: string, observation: string, step: number) => {
119+
onObservation: ({ tool, observation, step }) => {
120120
console.log(`\n[第 ${step + 1} 步 工具=${tool}]\n${observation}\n`)
121121
},
122122
}

0 commit comments

Comments
 (0)