feat(events): publish runtime service events

Migrate hook observation to runtime events and update the process hook notification protocol. Add runtime event publication for message bus failures, channel lifecycle/outbound flow, gateway reloads, MCP server state, and MCP tool calls.

Validation: go test ./pkg/events/... ./pkg/bus ./pkg/agent ./pkg/channels ./pkg/mcp ./pkg/tools/integration ./pkg/gateway; make lint
This commit is contained in:
Hoshina 2026-04-26 16:05:10 +08:00
parent eedebabbea
commit 8caf9aeb2b
27 changed files with 1394 additions and 104 deletions

View file

@ -13,7 +13,7 @@ The repository no longer ships standalone example source files. The Go and Pytho
| Type | Interface | Stage | Can modify data | | Type | Interface | Stage | Can modify data |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Observer | `EventObserver` | EventBus broadcast | No | | Observer | `RuntimeEventObserver` / `EventObserver` | Runtime event bus broadcast | No |
| LLM interceptor | `LLMInterceptor` | `before_llm` / `after_llm` | Yes | | LLM interceptor | `LLMInterceptor` | `before_llm` / `after_llm` | Yes |
| Tool interceptor | `ToolInterceptor` | `before_tool` / `after_tool` | Yes | | Tool interceptor | `ToolInterceptor` | `before_tool` / `after_tool` | Yes |
| Tool approver | `ToolApprover` | `approve_tool` | No, returns allow/deny | | Tool approver | `ToolApprover` | `approve_tool` | No, returns allow/deny |
@ -136,9 +136,9 @@ Example:
"/tmp/review_gate.py" "/tmp/review_gate.py"
], ],
"observe": [ "observe": [
"tool_exec_start", "agent.tool.exec_start",
"tool_exec_end", "agent.tool.exec_end",
"tool_exec_skipped" "agent.tool.exec_skipped"
], ],
"intercept": [ "intercept": [
"before_tool", "before_tool",
@ -174,7 +174,7 @@ Both examples are intentionally safe: they only log, never rewrite, and never de
The following is a minimal logging hook for in-process use. It implements: The following is a minimal logging hook for in-process use. It implements:
1. `EventObserver` 1. `RuntimeEventObserver`
2. `LLMInterceptor` 2. `LLMInterceptor`
3. `ToolInterceptor` 3. `ToolInterceptor`
4. `ToolApprover` 4. `ToolApprover`
@ -196,6 +196,7 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/agent"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
) )
@ -217,12 +218,12 @@ func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook {
} }
} }
func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error { func (h *ExampleLoggerHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error {
_ = ctx _ = ctx
if h == nil || !h.logEvents { if h == nil || !h.logEvents {
return nil return nil
} }
h.record("event", evt.Meta, map[string]any{ h.record("event", evt.Scope, map[string]any{
"event": evt.Kind.String(), "event": evt.Kind.String(),
"payload": evt.Payload, "payload": evt.Payload,
}, nil) }, nil)
@ -275,7 +276,7 @@ func (h *ExampleLoggerHook) ApproveTool(
return decision, nil return decision, nil
} }
func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) { func (h *ExampleLoggerHook) record(stage string, refs any, payload any, decision any) {
logger.InfoCF("hooks", "Example hook observed", map[string]any{ logger.InfoCF("hooks", "Example hook observed", map[string]any{
"stage": stage, "stage": stage,
}) })
@ -286,7 +287,7 @@ func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload a
entry := map[string]any{ entry := map[string]any{
"ts": time.Now().UTC(), "ts": time.Now().UTC(),
"stage": stage, "stage": stage,
"meta": meta, "refs": refs,
"payload": payload, "payload": payload,
"decision": decision, "decision": decision,
} }
@ -428,7 +429,7 @@ If you only see `before_llm` and `after_llm`, that usually means the request did
The following script is a minimal process-hook example. It uses only the Python standard library and supports: The following script is a minimal process-hook example. It uses only the Python standard library and supports:
1. `hook.hello` 1. `hook.hello`
2. `hook.event` 2. `hook.runtime_event`
3. `hook.before_tool` 3. `hook.before_tool`
4. `hook.approve_tool` 4. `hook.approve_tool`
@ -564,8 +565,8 @@ def main() -> int:
}) })
if not message_id: if not message_id:
if method == "hook.event" and LOG_EVENTS: if method == "hook.runtime_event" and LOG_EVENTS:
log_stderr(f"observed event: {params.get('Kind')}") log_stderr(f"observed event: {params.get('kind')}")
continue continue
try: try:
@ -606,9 +607,9 @@ if __name__ == "__main__":
"/abs/path/to/review_gate.py" "/abs/path/to/review_gate.py"
], ],
"observe": [ "observe": [
"tool_exec_start", "agent.tool.exec_start",
"tool_exec_end", "agent.tool.exec_end",
"tool_exec_skipped" "agent.tool.exec_skipped"
], ],
"intercept": [ "intercept": [
"before_tool", "before_tool",
@ -626,7 +627,7 @@ if __name__ == "__main__":
### Environment Variables ### Environment Variables
- `PICOCLAW_HOOK_LOG_EVENTS` - `PICOCLAW_HOOK_LOG_EVENTS`
Whether to write `hook.event` summaries to `stderr`, enabled by default Whether to write `hook.runtime_event` summaries to `stderr`, enabled by default
- `PICOCLAW_HOOK_LOG_FILE` - `PICOCLAW_HOOK_LOG_FILE`
Path to an external log file. When set, the script appends inbound hook requests, notifications, and outbound responses as JSON Lines Path to an external log file. When set, the script appends inbound hook requests, notifications, and outbound responses as JSON Lines
@ -645,7 +646,7 @@ Typical interpretation:
- Only `hook.hello` - Only `hook.hello`
The process started and completed the handshake, but no business hook request has arrived yet The process started and completed the handshake, but no business hook request has arrived yet
- `hook.event` - `hook.runtime_event`
The `observe` configuration is working The `observe` configuration is working
- `hook.before_tool` - `hook.before_tool`
The `intercept: ["before_tool", ...]` configuration is working The `intercept: ["before_tool", ...]` configuration is working
@ -664,7 +665,7 @@ A complete sample:
```json ```json
{"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false} {"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false}
{"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null} {"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null}
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.event","params":{"Kind":"tool_exec_start"},"notification":true} {"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.runtime_event","params":{"kind":"agent.tool.exec_start"},"notification":true}
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false} {"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false}
{"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null} {"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null}
``` ```
@ -672,7 +673,7 @@ A complete sample:
Additional notes: Additional notes:
- Timestamps are UTC - Timestamps are UTC
- `notification=true` means it was a notification such as `hook.event`, which does not expect a response - `notification=true` means it was a notification such as `hook.runtime_event`, which does not expect a response
- `id` increases within a single hook process; if the process restarts, the counter starts over - `id` increases within a single hook process; if the process restarts, the counter starts over
## Process-Hook Protocol ## Process-Hook Protocol
@ -681,7 +682,7 @@ Current process hooks use `JSON-RPC over stdio`:
- PicoClaw starts the external process - PicoClaw starts the external process
- Requests and responses are exchanged as one JSON message per line - Requests and responses are exchanged as one JSON message per line
- `hook.event` is a notification and does not need a response - `hook.runtime_event` is a notification and does not need a response
- `hook.before_llm`, `hook.after_llm`, `hook.before_tool`, `hook.after_tool`, and `hook.approve_tool` are request/response calls - `hook.before_llm`, `hook.after_llm`, `hook.before_tool`, `hook.after_tool`, and `hook.approve_tool` are request/response calls
The host does not currently accept new RPCs initiated by the process hook. In practice, that means an external hook can only respond to PicoClaw calls; it cannot call back into the host to send channel messages. The host does not currently accept new RPCs initiated by the process hook. In practice, that means an external hook can only respond to PicoClaw calls; it cannot call back into the host to send channel messages.

View file

@ -13,7 +13,7 @@
| 类型 | 接口 | 作用阶段 | 能否改写 | | 类型 | 接口 | 作用阶段 | 能否改写 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 观察型 | `EventObserver` | EventBus 广播事件时 | 否 | | 观察型 | `RuntimeEventObserver` / `EventObserver` | runtime event bus 广播事件时 | 否 |
| LLM 拦截型 | `LLMInterceptor` | `before_llm` / `after_llm` | 是 | | LLM 拦截型 | `LLMInterceptor` | `before_llm` / `after_llm` | 是 |
| Tool 拦截型 | `ToolInterceptor` | `before_tool` / `after_tool` | 是 | | Tool 拦截型 | `ToolInterceptor` | `before_tool` / `after_tool` | 是 |
| Tool 审批型 | `ToolApprover` | `approve_tool` | 否,返回批准/拒绝 | | Tool 审批型 | `ToolApprover` | `approve_tool` | 否,返回批准/拒绝 |
@ -136,9 +136,9 @@ HookManager 的排序规则是:
"/tmp/review_gate.py" "/tmp/review_gate.py"
], ],
"observe": [ "observe": [
"tool_exec_start", "agent.tool.exec_start",
"tool_exec_end", "agent.tool.exec_end",
"tool_exec_skipped" "agent.tool.exec_skipped"
], ],
"intercept": [ "intercept": [
"before_tool", "before_tool",
@ -174,7 +174,7 @@ tail -f /tmp/picoclaw-hook-review-gate.log
下面这段代码是一个最小的“记录型” in-process hook。它实现了 下面这段代码是一个最小的“记录型” in-process hook。它实现了
1. `EventObserver` 1. `RuntimeEventObserver`
2. `LLMInterceptor` 2. `LLMInterceptor`
3. `ToolInterceptor` 3. `ToolInterceptor`
4. `ToolApprover` 4. `ToolApprover`
@ -196,6 +196,7 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/agent"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
) )
@ -217,12 +218,12 @@ func NewExampleLoggerHook(opts ExampleLoggerHookOptions) *ExampleLoggerHook {
} }
} }
func (h *ExampleLoggerHook) OnEvent(ctx context.Context, evt agent.Event) error { func (h *ExampleLoggerHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error {
_ = ctx _ = ctx
if h == nil || !h.logEvents { if h == nil || !h.logEvents {
return nil return nil
} }
h.record("event", evt.Meta, map[string]any{ h.record("event", evt.Scope, map[string]any{
"event": evt.Kind.String(), "event": evt.Kind.String(),
"payload": evt.Payload, "payload": evt.Payload,
}, nil) }, nil)
@ -275,7 +276,7 @@ func (h *ExampleLoggerHook) ApproveTool(
return decision, nil return decision, nil
} }
func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload any, decision any) { func (h *ExampleLoggerHook) record(stage string, refs any, payload any, decision any) {
logger.InfoCF("hooks", "Example hook observed", map[string]any{ logger.InfoCF("hooks", "Example hook observed", map[string]any{
"stage": stage, "stage": stage,
}) })
@ -286,7 +287,7 @@ func (h *ExampleLoggerHook) record(stage string, meta agent.EventMeta, payload a
entry := map[string]any{ entry := map[string]any{
"ts": time.Now().UTC(), "ts": time.Now().UTC(),
"stage": stage, "stage": stage,
"meta": meta, "refs": refs,
"payload": payload, "payload": payload,
"decision": decision, "decision": decision,
} }
@ -428,7 +429,7 @@ func init() {
下面这段脚本是一个最小的 `process hook` 示例。它只使用 Python 标准库,支持: 下面这段脚本是一个最小的 `process hook` 示例。它只使用 Python 标准库,支持:
1. `hook.hello` 1. `hook.hello`
2. `hook.event` 2. `hook.runtime_event`
3. `hook.before_tool` 3. `hook.before_tool`
4. `hook.approve_tool` 4. `hook.approve_tool`
@ -564,8 +565,8 @@ def main() -> int:
}) })
if not message_id: if not message_id:
if method == "hook.event" and LOG_EVENTS: if method == "hook.runtime_event" and LOG_EVENTS:
log_stderr(f"observed event: {params.get('Kind')}") log_stderr(f"observed event: {params.get('kind')}")
continue continue
try: try:
@ -606,9 +607,9 @@ if __name__ == "__main__":
"/abs/path/to/review_gate.py" "/abs/path/to/review_gate.py"
], ],
"observe": [ "observe": [
"tool_exec_start", "agent.tool.exec_start",
"tool_exec_end", "agent.tool.exec_end",
"tool_exec_skipped" "agent.tool.exec_skipped"
], ],
"intercept": [ "intercept": [
"before_tool", "before_tool",
@ -626,7 +627,7 @@ if __name__ == "__main__":
### 环境变量 ### 环境变量
- `PICOCLAW_HOOK_LOG_EVENTS` - `PICOCLAW_HOOK_LOG_EVENTS`
是否把 `hook.event` 写到 `stderr`,默认开启 是否把 `hook.runtime_event` 写到 `stderr`,默认开启
- `PICOCLAW_HOOK_LOG_FILE` - `PICOCLAW_HOOK_LOG_FILE`
外部日志文件路径。设置后,脚本会把收到的 hook 请求、notification 和返回结果按 JSON Lines 追加到该文件 外部日志文件路径。设置后,脚本会把收到的 hook 请求、notification 和返回结果按 JSON Lines 追加到该文件
@ -645,7 +646,7 @@ if __name__ == "__main__":
- 只看到 `hook.hello` - 只看到 `hook.hello`
说明进程启动并完成握手了,但还没有新的业务 hook 请求真正打进来 说明进程启动并完成握手了,但还没有新的业务 hook 请求真正打进来
- 看到 `hook.event` - 看到 `hook.runtime_event`
说明 `observe` 配置生效了 说明 `observe` 配置生效了
- 看到 `hook.before_tool` - 看到 `hook.before_tool`
说明 `intercept: ["before_tool", ...]` 生效了 说明 `intercept: ["before_tool", ...]` 生效了
@ -664,7 +665,7 @@ if __name__ == "__main__":
```json ```json
{"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false} {"ts":"2026-03-21T14:12:00+00:00","direction":"in","id":1,"method":"hook.hello","params":{"name":"py_review_gate","version":1,"modes":["observe","tool","approve"]},"notification":false}
{"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null} {"ts":"2026-03-21T14:12:00+00:00","direction":"out","id":1,"response":{"ok":true,"name":"python-review-gate"},"error":null}
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.event","params":{"Kind":"tool_exec_start"},"notification":true} {"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":0,"method":"hook.runtime_event","params":{"kind":"agent.tool.exec_start"},"notification":true}
{"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false} {"ts":"2026-03-21T14:12:05+00:00","direction":"in","id":7,"method":"hook.before_tool","params":{"tool":"echo_text","arguments":{"text":"hello"}},"notification":false}
{"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null} {"ts":"2026-03-21T14:12:05+00:00","direction":"out","id":7,"response":{"action":"continue"},"error":null}
``` ```
@ -672,7 +673,7 @@ if __name__ == "__main__":
补充说明: 补充说明:
- 时间戳是 UTC不是本地时区 - 时间戳是 UTC不是本地时区
- `notification=true` 表示这是 `hook.event` 这类不需要响应的通知 - `notification=true` 表示这是 `hook.runtime_event` 这类不需要响应的通知
- `id` 会随着当前进程内的请求递增;如果 hook 进程重启,计数会重新开始 - `id` 会随着当前进程内的请求递增;如果 hook 进程重启,计数会重新开始
## Process Hook 协议约定 ## Process Hook 协议约定
@ -681,7 +682,7 @@ if __name__ == "__main__":
- PicoClaw 启动外部进程 - PicoClaw 启动外部进程
- 请求和响应都按“一行一个 JSON 消息”传输 - 请求和响应都按“一行一个 JSON 消息”传输
- `hook.event` 是 notification不需要响应 - `hook.runtime_event` 是 notification不需要响应
- `hook.before_llm` / `hook.after_llm` / `hook.before_tool` / `hook.after_tool` / `hook.approve_tool` 是 request/response - `hook.before_llm` / `hook.after_llm` / `hook.before_tool` / `hook.after_tool` / `hook.approve_tool` 是 request/response
当前宿主不会接受 process hook 主动发起的新 RPC。也就是说外部 hook 现在只能“响应 PicoClaw 的调用”,不能反向调用宿主去发送 channel 消息。 当前宿主不会接受 process hook 主动发起的新 RPC。也就是说外部 hook 现在只能“响应 PicoClaw 的调用”,不能反向调用宿主去发送 channel 消息。

View file

@ -437,21 +437,28 @@ Approval hook for deciding whether to allow execution of sensitive tools.
--- ---
## 7. `hook.event` (notification) ## 7. `hook.runtime_event` (notification)
Observer event, broadcast only, no response required. `id` is `0` or absent. Runtime observer event, broadcast only, no response required. `id` is `0` or absent.
```json ```json
{ {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": "hook.event", "method": "hook.runtime_event",
"params": { "params": {
"Kind": "tool_exec_start", "kind": "agent.tool.exec_start",
"Meta": { "source": {
"AgentID": "agent-1", "component": "agent",
"TurnID": "turn-1" "name": "agent-1"
}, },
"Payload": { "scope": {
"agent_id": "agent-1",
"session_key": "session-1",
"turn_id": "turn-1",
"channel": "cli",
"chat_id": "chat-1"
},
"payload": {
"Tool": "echo_text", "Tool": "echo_text",
"Arguments": {"text": "hello"} "Arguments": {"text": "hello"}
} }
@ -460,12 +467,14 @@ Observer event, broadcast only, no response required. `id` is `0` or absent.
``` ```
Common `Kind` values: Common `Kind` values:
- `turn_start` / `turn_end` - `agent.turn.start` / `agent.turn.end`
- `llm_request` / `llm_response` - `agent.llm.request` / `agent.llm.response`
- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped` - `agent.tool.exec_start` / `agent.tool.exec_end` / `agent.tool.exec_skipped`
- `steering_injected` - `agent.steering.injected`
- `interrupt_received` - `agent.interrupt.received`
- `error` - `agent.error`
Legacy observe configuration names such as `turn_end` and `tool_exec_start` are still accepted and normalized to runtime event names. New process hook notifications use `hook.runtime_event`.
--- ---

View file

@ -437,21 +437,28 @@
--- ---
## 7. `hook.event`notification ## 7. `hook.runtime_event`notification
观察型事件,仅广播,无需响应。`id``0` 或不存在。 runtime 观察型事件,仅广播,无需响应。`id``0` 或不存在。
```json ```json
{ {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": "hook.event", "method": "hook.runtime_event",
"params": { "params": {
"Kind": "tool_exec_start", "kind": "agent.tool.exec_start",
"Meta": { "source": {
"AgentID": "agent-1", "component": "agent",
"TurnID": "turn-1" "name": "agent-1"
}, },
"Payload": { "scope": {
"agent_id": "agent-1",
"session_key": "session-1",
"turn_id": "turn-1",
"channel": "cli",
"chat_id": "chat-1"
},
"payload": {
"Tool": "echo_text", "Tool": "echo_text",
"Arguments": {"text": "hello"} "Arguments": {"text": "hello"}
} }
@ -460,12 +467,14 @@
``` ```
常见 `Kind` 值: 常见 `Kind` 值:
- `turn_start` / `turn_end` - `agent.turn.start` / `agent.turn.end`
- `llm_request` / `llm_response` - `agent.llm.request` / `agent.llm.response`
- `tool_exec_start` / `tool_exec_end` / `tool_exec_skipped` - `agent.tool.exec_start` / `agent.tool.exec_end` / `agent.tool.exec_skipped`
- `steering_injected` - `agent.steering.injected`
- `interrupt_received` - `agent.interrupt.received`
- `error` - `agent.error`
旧 observe 配置名如 `turn_end``tool_exec_start` 仍然可用,并会归一化为 runtime event 名称。新的 process hook 通知使用 `hook.runtime_event`
--- ---

View file

@ -206,3 +206,11 @@ func (al *AgentLoop) RuntimeEventStats() runtimeevents.Stats {
} }
return al.runtimeEvents.Stats() return al.runtimeEvents.Stats()
} }
// RuntimeEventBus returns the runtime event bus used by the agent loop.
func (al *AgentLoop) RuntimeEventBus() runtimeevents.Bus {
if al == nil {
return nil
}
return al.runtimeEvents
}

View file

@ -79,7 +79,7 @@ func NewAgentLoop(
al.ownsRuntimeEvents = true al.ownsRuntimeEvents = true
} }
al.providerFactory = providers.CreateProviderFromConfig al.providerFactory = providers.CreateProviderFromConfig
al.hooks = NewHookManager(eventBus) al.hooks = NewHookManagerWithRuntimeEvents(eventBus, al.runtimeEvents.Channel())
configureHookManagerFromConfig(al.hooks, cfg) configureHookManagerFromConfig(al.hooks, cfg)
al.contextManager = al.resolveContextManager() al.contextManager = al.resolveContextManager()

View file

@ -97,7 +97,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
} }
al.mcp.initOnce.Do(func() { al.mcp.initOnce.Do(func() {
mcpManager := mcp.NewManager() mcpManager := mcp.NewManager(mcp.WithRuntimeEvents(al.runtimeEvents))
defaultAgent := al.registry.GetDefaultAgent() defaultAgent := al.registry.GetDefaultAgent()
workspacePath := al.cfg.WorkspacePath() workspacePath := al.cfg.WorkspacePath()
@ -164,6 +164,7 @@ func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
mcpTool.SetWorkspace(agent.Workspace) mcpTool.SetWorkspace(agent.Workspace)
mcpTool.SetMaxInlineTextRunes(al.cfg.Tools.MCP.GetMaxInlineTextChars()) mcpTool.SetMaxInlineTextRunes(al.cfg.Tools.MCP.GetMaxInlineTextChars())
mcpTool.SetEventPublisher(al.runtimeEvents)
if registerAsHidden { if registerAsHidden {
agent.Tools.RegisterHidden(mcpTool) agent.Tools.RegisterHidden(mcpTool)

View file

@ -8,6 +8,7 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
) )
type hookRuntime struct { type hookRuntime struct {
@ -295,10 +296,11 @@ func processHookObserveKindsFromConfig(observe []string) ([]string, bool, error)
case "", "*", "all": case "", "*", "all":
return nil, true, nil return nil, true, nil
default: default:
if _, ok := validKinds[kind]; !ok { normalizedKind, ok := validKinds[kind]
if !ok {
return nil, false, fmt.Errorf("unsupported observe event %q", kind) return nil, false, fmt.Errorf("unsupported observe event %q", kind)
} }
normalized = append(normalized, kind) normalized = append(normalized, normalizedKind)
} }
} }
@ -308,10 +310,15 @@ func processHookObserveKindsFromConfig(observe []string) ([]string, bool, error)
return normalized, true, nil return normalized, true, nil
} }
func validHookEventKinds() map[string]struct{} { func validHookEventKinds() map[string]string {
kinds := make(map[string]struct{}, int(eventKindCount)) kinds := make(map[string]string, int(eventKindCount)*2)
for kind := EventKind(0); kind < eventKindCount; kind++ { for kind := EventKind(0); kind < eventKindCount; kind++ {
kinds[kind.String()] = struct{}{} runtimeKind := runtimeKindForAgentEvent(kind).String()
kinds[kind.String()] = runtimeKind
kinds[runtimeKind] = runtimeKind
} }
kinds[runtimeevents.KindAgentToolExecStart.String()] = runtimeevents.KindAgentToolExecStart.String()
kinds[runtimeevents.KindAgentToolExecEnd.String()] = runtimeevents.KindAgentToolExecEnd.String()
kinds[runtimeevents.KindAgentToolExecSkipped.String()] = runtimeevents.KindAgentToolExecSkipped.String()
return kinds return kinds
} }

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"path/filepath" "path/filepath"
"slices"
"testing" "testing"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
@ -155,7 +156,25 @@ func TestAgentLoop_ProcessDirectWithChannel_AutoMountsProcessHook(t *testing.T)
t.Fatalf("expected process model, got %q", lastModel) t.Fatalf("expected process model, got %q", lastModel)
} }
waitForFileContains(t, eventLog, "turn_end") waitForFileContains(t, eventLog, "agent.turn.end")
}
func TestProcessHookObserveKindsFromConfigAcceptsRuntimeNames(t *testing.T) {
kinds, enabled, err := processHookObserveKindsFromConfig([]string{
"tool_exec_start",
"agent.tool.exec_end",
})
if err != nil {
t.Fatalf("processHookObserveKindsFromConfig failed: %v", err)
}
if !enabled {
t.Fatal("expected observe to be enabled")
}
want := []string{"agent.tool.exec_start", "agent.tool.exec_end"}
if !slices.Equal(kinds, want) {
t.Fatalf("observe kinds = %v, want %v", kinds, want)
}
} }
func TestAgentLoop_ProcessDirectWithChannel_InvalidConfiguredHookFails(t *testing.T) { func TestAgentLoop_ProcessDirectWithChannel_InvalidConfiguredHookFails(t *testing.T) {

View file

@ -12,6 +12,7 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/isolation" "github.com/sipeed/picoclaw/pkg/isolation"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
@ -188,13 +189,26 @@ func (ph *ProcessHook) OnEvent(ctx context.Context, evt Event) error {
return nil return nil
} }
if len(ph.observeKinds) > 0 { if len(ph.observeKinds) > 0 {
if _, ok := ph.observeKinds[evt.Kind.String()]; !ok { kind := runtimeKindForAgentEvent(evt.Kind).String()
if _, ok := ph.observeKinds[kind]; !ok {
return nil return nil
} }
} }
return ph.notify(ctx, "hook.event", evt) return ph.notify(ctx, "hook.event", evt)
} }
func (ph *ProcessHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error {
if ph == nil || !ph.opts.Observe {
return nil
}
if len(ph.observeKinds) > 0 {
if _, ok := ph.observeKinds[evt.Kind.String()]; !ok {
return nil
}
}
return ph.notify(ctx, "hook.runtime_event", evt)
}
func (ph *ProcessHook) BeforeLLM( func (ph *ProcessHook) BeforeLLM(
ctx context.Context, ctx context.Context,
req *LLMHookRequest, req *LLMHookRequest,

View file

@ -66,7 +66,7 @@ func TestAgentLoop_MountProcessHook_LLMAndObserver(t *testing.T) {
t.Fatalf("expected process model, got %q", lastModel) t.Fatalf("expected process model, got %q", lastModel)
} }
waitForFileContains(t, eventLog, "turn_end") waitForFileContains(t, eventLog, "agent.turn.end")
} }
func TestAgentLoop_MountProcessHook_ToolRewrite(t *testing.T) { func TestAgentLoop_MountProcessHook_ToolRewrite(t *testing.T) {
@ -350,10 +350,12 @@ func runProcessHookHelper() error {
} }
if msg.ID == 0 { if msg.ID == 0 {
if msg.Method == "hook.event" && eventLog != "" { if (msg.Method == "hook.event" || msg.Method == "hook.runtime_event") && eventLog != "" {
var evt map[string]any var evt map[string]any
if err := json.Unmarshal(msg.Params, &evt); err == nil { if err := json.Unmarshal(msg.Params, &evt); err == nil {
if rawKind, ok := evt["Kind"].(float64); ok { if kind, ok := evt["kind"].(string); ok {
_ = os.WriteFile(eventLog, []byte(kind+"\n"), 0o644)
} else if rawKind, ok := evt["Kind"].(float64); ok {
kind := EventKind(rawKind) kind := EventKind(rawKind)
_ = os.WriteFile(eventLog, []byte(kind.String()+"\n"), 0o644) _ = os.WriteFile(eventLog, []byte(kind.String()+"\n"), 0o644)
} }

View file

@ -9,6 +9,7 @@ import (
"sync" "sync"
"time" "time"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
@ -75,6 +76,10 @@ type EventObserver interface {
OnEvent(ctx context.Context, evt Event) error OnEvent(ctx context.Context, evt Event) error
} }
type RuntimeEventObserver interface {
OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error
}
type LLMInterceptor interface { type LLMInterceptor interface {
BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLMHookRequest, HookDecision, error) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLMHookRequest, HookDecision, error)
AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LLMHookResponse, HookDecision, error) AfterLLM(ctx context.Context, resp *LLMHookResponse) (*LLMHookResponse, HookDecision, error)
@ -193,6 +198,7 @@ func (r *ToolResultHookResponse) Clone() *ToolResultHookResponse {
type HookManager struct { type HookManager struct {
eventBus *EventBus eventBus *EventBus
runtimeEvents runtimeevents.EventChannel
observerTimeout time.Duration observerTimeout time.Duration
interceptorTimeout time.Duration interceptorTimeout time.Duration
approvalTimeout time.Duration approvalTimeout time.Duration
@ -201,28 +207,56 @@ type HookManager struct {
hooks map[string]HookRegistration hooks map[string]HookRegistration
ordered []HookRegistration ordered []HookRegistration
sub EventSubscription sub EventSubscription
done chan struct{} runtimeSub runtimeevents.Subscription
closeOnce sync.Once done chan struct{}
runtimeDone chan struct{}
runtimeObserveEnabled bool
closeOnce sync.Once
} }
func NewHookManager(eventBus *EventBus) *HookManager { func NewHookManager(eventBus *EventBus) *HookManager {
return NewHookManagerWithRuntimeEvents(eventBus, nil)
}
func NewHookManagerWithRuntimeEvents(eventBus *EventBus, runtimeEvents runtimeevents.EventChannel) *HookManager {
hm := &HookManager{ hm := &HookManager{
eventBus: eventBus, eventBus: eventBus,
runtimeEvents: runtimeEvents,
observerTimeout: defaultHookObserverTimeout, observerTimeout: defaultHookObserverTimeout,
interceptorTimeout: defaultHookInterceptorTimeout, interceptorTimeout: defaultHookInterceptorTimeout,
approvalTimeout: defaultHookApprovalTimeout, approvalTimeout: defaultHookApprovalTimeout,
hooks: make(map[string]HookRegistration), hooks: make(map[string]HookRegistration),
done: make(chan struct{}), done: make(chan struct{}),
runtimeDone: make(chan struct{}),
} }
if eventBus == nil { if eventBus != nil {
hm.sub = eventBus.Subscribe(hookObserverBufferSize)
go hm.dispatchEvents()
} else {
close(hm.done) close(hm.done)
return hm
} }
hm.sub = eventBus.Subscribe(hookObserverBufferSize) if runtimeEvents != nil {
go hm.dispatchEvents() sub, ch, err := runtimeEvents.SubscribeChan(context.Background(), runtimeevents.SubscribeOptions{
Name: "hook-manager-observer",
Buffer: hookObserverBufferSize,
})
if err != nil {
logger.WarnCF("hooks", "Failed to subscribe runtime events for hooks", map[string]any{
"error": err.Error(),
})
close(hm.runtimeDone)
} else {
hm.runtimeSub = sub
hm.runtimeObserveEnabled = true
go hm.dispatchRuntimeEvents(ch)
}
} else {
close(hm.runtimeDone)
}
return hm return hm
} }
@ -235,7 +269,15 @@ func (hm *HookManager) Close() {
if hm.eventBus != nil { if hm.eventBus != nil {
hm.eventBus.Unsubscribe(hm.sub.ID) hm.eventBus.Unsubscribe(hm.sub.ID)
} }
if hm.runtimeSub != nil {
if err := hm.runtimeSub.Close(); err != nil {
logger.WarnCF("hooks", "Failed to close runtime event hook subscription", map[string]any{
"error": err.Error(),
})
}
}
<-hm.done <-hm.done
<-hm.runtimeDone
hm.closeAllHooks() hm.closeAllHooks()
}) })
} }
@ -297,6 +339,11 @@ func (hm *HookManager) dispatchEvents() {
for evt := range hm.sub.C { for evt := range hm.sub.C {
for _, reg := range hm.snapshotHooks() { for _, reg := range hm.snapshotHooks() {
if hm.runtimeObserveEnabled {
if _, ok := reg.Hook.(RuntimeEventObserver); ok {
continue
}
}
observer, ok := reg.Hook.(EventObserver) observer, ok := reg.Hook.(EventObserver)
if !ok { if !ok {
continue continue
@ -306,6 +353,20 @@ func (hm *HookManager) dispatchEvents() {
} }
} }
func (hm *HookManager) dispatchRuntimeEvents(ch <-chan runtimeevents.Event) {
defer close(hm.runtimeDone)
for evt := range ch {
for _, reg := range hm.snapshotHooks() {
observer, ok := reg.Hook.(RuntimeEventObserver)
if !ok {
continue
}
hm.runRuntimeObserver(reg.Name, observer, evt)
}
}
}
func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLMHookRequest, HookDecision) { func (hm *HookManager) BeforeLLM(ctx context.Context, req *LLMHookRequest) (*LLMHookRequest, HookDecision) {
if hm == nil || req == nil { if hm == nil || req == nil {
return req, HookDecision{Action: HookActionContinue} return req, HookDecision{Action: HookActionContinue}
@ -608,6 +669,37 @@ func (hm *HookManager) runObserver(name string, observer EventObserver, evt Even
} }
} }
func (hm *HookManager) runRuntimeObserver(
name string,
observer RuntimeEventObserver,
evt runtimeevents.Event,
) {
ctx, cancel := context.WithTimeout(context.Background(), hm.observerTimeout)
defer cancel()
done := make(chan error, 1)
go func() {
done <- observer.OnRuntimeEvent(ctx, evt)
}()
select {
case err := <-done:
if err != nil {
logger.WarnCF("hooks", "Runtime event observer failed", map[string]any{
"hook": name,
"event": evt.Kind.String(),
"error": err.Error(),
})
}
case <-ctx.Done():
logger.WarnCF("hooks", "Runtime event observer timed out", map[string]any{
"hook": name,
"event": evt.Kind.String(),
"timeout_ms": hm.observerTimeout.Milliseconds(),
})
}
}
func (hm *HookManager) callBeforeLLM( func (hm *HookManager) callBeforeLLM(
parent context.Context, parent context.Context,
name string, name string,

View file

@ -12,6 +12,7 @@ import (
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
@ -150,6 +151,31 @@ func (h *llmObserverHook) AfterLLM(
return next, HookDecision{Action: HookActionModify}, nil return next, HookDecision{Action: HookActionModify}, nil
} }
type dualRuntimeObserverHook struct {
legacyCh chan Event
runtimeCh chan runtimeevents.Event
}
func (h *dualRuntimeObserverHook) OnEvent(ctx context.Context, evt Event) error {
if evt.Kind == EventKindTurnEnd {
select {
case h.legacyCh <- evt:
default:
}
}
return nil
}
func (h *dualRuntimeObserverHook) OnRuntimeEvent(ctx context.Context, evt runtimeevents.Event) error {
if evt.Kind == runtimeevents.KindAgentTurnEnd {
select {
case h.runtimeCh <- evt:
default:
}
}
return nil
}
type llmSystemRewriteHook struct{} type llmSystemRewriteHook struct{}
func (h *llmSystemRewriteHook) BeforeLLM( func (h *llmSystemRewriteHook) BeforeLLM(
@ -498,6 +524,65 @@ func TestAgentLoop_Hooks_ObserverAndLLMInterceptor(t *testing.T) {
} }
} }
func TestAgentLoop_Hooks_RuntimeObserverPreferredOverLegacyObserver(t *testing.T) {
provider := &llmHookTestProvider{}
al, agent, cleanup := newHookTestLoop(t, provider)
defer cleanup()
hook := &dualRuntimeObserverHook{
legacyCh: make(chan Event, 1),
runtimeCh: make(chan runtimeevents.Event, 1),
}
if err := al.MountHook(NamedHook("runtime-observer", hook)); err != nil {
t.Fatalf("MountHook failed: %v", err)
}
resp, err := al.runAgentLoop(context.Background(), agent, processOptions{
SessionKey: "session-1",
Channel: "cli",
ChatID: "direct",
UserMessage: "hello",
DefaultResponse: defaultResponse,
EnableSummary: false,
SendResponse: false,
InboundContext: &bus.InboundContext{
Channel: "cli",
Account: "default",
ChatID: "direct",
ChatType: "direct",
SenderID: "hook-user",
MessageID: "msg-1",
},
})
if err != nil {
t.Fatalf("runAgentLoop failed: %v", err)
}
if resp != "provider content" {
t.Fatalf("expected provider content, got %q", resp)
}
select {
case evt := <-hook.runtimeCh:
if evt.Kind != runtimeevents.KindAgentTurnEnd {
t.Fatalf("runtime observer kind = %q", evt.Kind)
}
if evt.Scope.SessionKey != "session-1" ||
evt.Scope.Channel != "cli" ||
evt.Scope.ChatID != "direct" ||
evt.Scope.MessageID != "msg-1" {
t.Fatalf("runtime observer scope = %+v", evt.Scope)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for runtime observer event")
}
select {
case evt := <-hook.legacyCh:
t.Fatalf("legacy observer unexpectedly received %v", evt.Kind)
case <-time.After(100 * time.Millisecond):
}
}
func TestAgentLoop_BtwCommand_UsesLLMHooks(t *testing.T) { func TestAgentLoop_BtwCommand_UsesLLMHooks(t *testing.T) {
provider := &llmHookTestProvider{} provider := &llmHookTestProvider{}
al, agent, cleanup := newHookTestLoop(t, provider) al, agent, cleanup := newHookTestLoop(t, provider)

View file

@ -6,6 +6,7 @@ import (
"sync" "sync"
"sync/atomic" "sync/atomic"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
) )
@ -48,6 +49,12 @@ type MessageBus struct {
closed atomic.Bool closed atomic.Bool
wg sync.WaitGroup wg sync.WaitGroup
streamDelegate atomic.Value // stores StreamDelegate streamDelegate atomic.Value // stores StreamDelegate
eventPublisher atomic.Value // stores EventPublisher
}
// EventPublisher is the minimal runtime event publisher used by MessageBus.
type EventPublisher interface {
Publish(ctx context.Context, evt runtimeevents.Event) runtimeevents.PublishResult
} }
func NewMessageBus() *MessageBus { func NewMessageBus() *MessageBus {
@ -92,9 +99,14 @@ func publish[T any](ctx context.Context, mb *MessageBus, ch chan T, msg T) error
func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error { func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {
msg = NormalizeInboundMessage(msg) msg = NormalizeInboundMessage(msg)
if msg.Context.isZero() { if msg.Context.isZero() {
mb.publishFailure("inbound", runtimeScopeFromInboundContext(msg.Context), ErrMissingInboundContext)
return ErrMissingInboundContext return ErrMissingInboundContext
} }
return publish(ctx, mb, mb.inbound, msg) if err := publish(ctx, mb, mb.inbound, msg); err != nil {
mb.publishFailure("inbound", runtimeScopeFromInboundContext(msg.Context), err)
return err
}
return nil
} }
func (mb *MessageBus) InboundChan() <-chan InboundMessage { func (mb *MessageBus) InboundChan() <-chan InboundMessage {
@ -104,9 +116,14 @@ func (mb *MessageBus) InboundChan() <-chan InboundMessage {
func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error { func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {
msg = NormalizeOutboundMessage(msg) msg = NormalizeOutboundMessage(msg)
if msg.Context.isZero() { if msg.Context.isZero() {
mb.publishFailure("outbound", runtimeScopeFromInboundContext(msg.Context), ErrMissingOutboundContext)
return ErrMissingOutboundContext return ErrMissingOutboundContext
} }
return publish(ctx, mb, mb.outbound, msg) if err := publish(ctx, mb, mb.outbound, msg); err != nil {
mb.publishFailure("outbound", runtimeScopeFromInboundContext(msg.Context), err)
return err
}
return nil
} }
func (mb *MessageBus) OutboundChan() <-chan OutboundMessage { func (mb *MessageBus) OutboundChan() <-chan OutboundMessage {
@ -116,9 +133,14 @@ func (mb *MessageBus) OutboundChan() <-chan OutboundMessage {
func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error { func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {
msg = NormalizeOutboundMediaMessage(msg) msg = NormalizeOutboundMediaMessage(msg)
if msg.Context.isZero() { if msg.Context.isZero() {
mb.publishFailure("outbound_media", runtimeScopeFromInboundContext(msg.Context), ErrMissingOutboundMediaContext)
return ErrMissingOutboundMediaContext return ErrMissingOutboundMediaContext
} }
return publish(ctx, mb, mb.outboundMedia, msg) if err := publish(ctx, mb, mb.outboundMedia, msg); err != nil {
mb.publishFailure("outbound_media", runtimeScopeFromInboundContext(msg.Context), err)
return err
}
return nil
} }
func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage { func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage {
@ -126,7 +148,11 @@ func (mb *MessageBus) OutboundMediaChan() <-chan OutboundMediaMessage {
} }
func (mb *MessageBus) PublishAudioChunk(ctx context.Context, chunk AudioChunk) error { func (mb *MessageBus) PublishAudioChunk(ctx context.Context, chunk AudioChunk) error {
return publish(ctx, mb, mb.audioChunks, chunk) if err := publish(ctx, mb, mb.audioChunks, chunk); err != nil {
mb.publishFailure("audio_chunk", runtimeScopeFromAudioChunk(chunk), err)
return err
}
return nil
} }
func (mb *MessageBus) AudioChunksChan() <-chan AudioChunk { func (mb *MessageBus) AudioChunksChan() <-chan AudioChunk {
@ -134,7 +160,11 @@ func (mb *MessageBus) AudioChunksChan() <-chan AudioChunk {
} }
func (mb *MessageBus) PublishVoiceControl(ctx context.Context, ctrl VoiceControl) error { func (mb *MessageBus) PublishVoiceControl(ctx context.Context, ctrl VoiceControl) error {
return publish(ctx, mb, mb.voiceControls, ctrl) if err := publish(ctx, mb, mb.voiceControls, ctrl); err != nil {
mb.publishFailure("voice_control", runtimeScopeFromVoiceControl(ctrl), err)
return err
}
return nil
} }
func (mb *MessageBus) VoiceControlsChan() <-chan VoiceControl { func (mb *MessageBus) VoiceControlsChan() <-chan VoiceControl {
@ -146,6 +176,11 @@ func (mb *MessageBus) SetStreamDelegate(d StreamDelegate) {
mb.streamDelegate.Store(d) mb.streamDelegate.Store(d)
} }
// SetEventPublisher registers a runtime event publisher for bus errors and lifecycle events.
func (mb *MessageBus) SetEventPublisher(p EventPublisher) {
mb.eventPublisher.Store(p)
}
// GetStreamer returns a Streamer for the given channel+chatID via the delegate. // GetStreamer returns a Streamer for the given channel+chatID via the delegate.
func (mb *MessageBus) GetStreamer(ctx context.Context, channel, chatID string) (Streamer, bool) { func (mb *MessageBus) GetStreamer(ctx context.Context, channel, chatID string) (Streamer, bool) {
if d, ok := mb.streamDelegate.Load().(StreamDelegate); ok && d != nil { if d, ok := mb.streamDelegate.Load().(StreamDelegate); ok && d != nil {
@ -156,6 +191,7 @@ func (mb *MessageBus) GetStreamer(ctx context.Context, channel, chatID string) (
func (mb *MessageBus) Close() { func (mb *MessageBus) Close() {
mb.closeOnce.Do(func() { mb.closeOnce.Do(func() {
mb.publishCloseEvent(runtimeevents.KindBusCloseStarted, 0)
// notify all blocked publishers to exit // notify all blocked publishers to exit
close(mb.done) close(mb.done)
@ -195,6 +231,8 @@ func (mb *MessageBus) Close() {
logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{ logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{
"count": drained, "count": drained,
}) })
mb.publishCloseEvent(runtimeevents.KindBusCloseDrained, drained)
} }
mb.publishCloseEvent(runtimeevents.KindBusCloseCompleted, drained)
}) })
} }

View file

@ -5,6 +5,8 @@ import (
"sync" "sync"
"testing" "testing"
"time" "time"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
) )
func TestPublishConsume(t *testing.T) { func TestPublishConsume(t *testing.T) {
@ -171,6 +173,76 @@ func TestPublishInbound_BackfillsContextFromLegacyFields(t *testing.T) {
} }
} }
func TestMessageBusPublishesRuntimeFailureAndCloseEvents(t *testing.T) {
eventBus := runtimeevents.NewBus()
defer func() {
if err := eventBus.Close(); err != nil {
t.Errorf("event bus close failed: %v", err)
}
}()
_, eventsCh, err := eventBus.Channel().OfKind(
runtimeevents.KindBusPublishFailed,
runtimeevents.KindBusCloseStarted,
runtimeevents.KindBusCloseDrained,
runtimeevents.KindBusCloseCompleted,
).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "bus-events", Buffer: 4})
if err != nil {
t.Fatalf("SubscribeChan failed: %v", err)
}
mb := NewMessageBus()
mb.SetEventPublisher(eventBus)
if err := mb.PublishInbound(context.Background(), InboundMessage{}); err == nil {
t.Fatal("expected PublishInbound to fail")
}
failed := receiveBusRuntimeEvent(t, eventsCh)
if failed.Kind != runtimeevents.KindBusPublishFailed ||
failed.Source.Name != "inbound" ||
failed.Severity != runtimeevents.SeverityError {
t.Fatalf("publish failed event = %+v", failed)
}
if err := mb.PublishOutbound(context.Background(), OutboundMessage{
Context: NewOutboundContext("telegram", "chat-1", ""),
Content: "queued",
}); err != nil {
t.Fatalf("PublishOutbound failed: %v", err)
}
mb.Close()
seen := map[runtimeevents.Kind]bool{}
for range 3 {
evt := receiveBusRuntimeEvent(t, eventsCh)
seen[evt.Kind] = true
}
for _, kind := range []runtimeevents.Kind{
runtimeevents.KindBusCloseStarted,
runtimeevents.KindBusCloseDrained,
runtimeevents.KindBusCloseCompleted,
} {
if !seen[kind] {
t.Fatalf("missing %s event, seen=%v", kind, seen)
}
}
}
func receiveBusRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event {
t.Helper()
select {
case evt, ok := <-ch:
if !ok {
t.Fatal("runtime event channel closed before expected event")
}
return evt
case <-time.After(time.Second):
t.Fatal("timed out waiting for runtime event")
return runtimeevents.Event{}
}
}
func TestPublishOutboundSubscribe(t *testing.T) { func TestPublishOutboundSubscribe(t *testing.T) {
mb := NewMessageBus() mb := NewMessageBus()
defer mb.Close() defer mb.Close()

88
pkg/bus/events.go Normal file
View file

@ -0,0 +1,88 @@
package bus
import (
"context"
"time"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
)
const busEventPublishTimeout = 100 * time.Millisecond
type busPublishFailedPayload struct {
Stream string `json:"stream"`
Error string `json:"error"`
}
type busClosePayload struct {
Drained int `json:"drained,omitempty"`
}
func (mb *MessageBus) publishFailure(stream string, scope runtimeevents.Scope, err error) {
if mb == nil || err == nil {
return
}
publisher, ok := mb.eventPublisher.Load().(EventPublisher)
if !ok || publisher == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), busEventPublishTimeout)
defer cancel()
publisher.Publish(ctx, runtimeevents.Event{
Kind: runtimeevents.KindBusPublishFailed,
Source: runtimeevents.Source{Component: "bus", Name: stream},
Scope: scope,
Severity: runtimeevents.SeverityError,
Payload: busPublishFailedPayload{
Stream: stream,
Error: err.Error(),
},
})
}
func (mb *MessageBus) publishCloseEvent(kind runtimeevents.Kind, drained int) {
if mb == nil {
return
}
publisher, ok := mb.eventPublisher.Load().(EventPublisher)
if !ok || publisher == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), busEventPublishTimeout)
defer cancel()
publisher.Publish(ctx, runtimeevents.Event{
Kind: kind,
Source: runtimeevents.Source{Component: "bus"},
Severity: runtimeevents.SeverityInfo,
Payload: busClosePayload{Drained: drained},
})
}
func runtimeScopeFromInboundContext(ctx InboundContext) runtimeevents.Scope {
return runtimeevents.Scope{
Channel: ctx.Channel,
Account: ctx.Account,
ChatID: ctx.ChatID,
TopicID: ctx.TopicID,
SpaceID: ctx.SpaceID,
SpaceType: ctx.SpaceType,
ChatType: ctx.ChatType,
SenderID: ctx.SenderID,
MessageID: ctx.MessageID,
}
}
func runtimeScopeFromAudioChunk(chunk AudioChunk) runtimeevents.Scope {
return runtimeevents.Scope{
Channel: chunk.Channel,
ChatID: chunk.ChatID,
}
}
func runtimeScopeFromVoiceControl(ctrl VoiceControl) runtimeevents.Scope {
return runtimeevents.Scope{
ChatID: ctrl.ChatID,
}
}

168
pkg/channels/events.go Normal file
View file

@ -0,0 +1,168 @@
package channels
import (
"context"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
)
const channelEventPublishTimeout = 100 * time.Millisecond
func channelTypeForEvent(m *Manager, channelName string) string {
if m == nil || m.config == nil {
return channelName
}
if bc := m.config.Channels.Get(channelName); bc != nil && bc.Type != "" {
return bc.Type
}
return channelName
}
func (m *Manager) publishChannelEvent(
kind runtimeevents.Kind,
channelName string,
scope runtimeevents.Scope,
severity runtimeevents.Severity,
payload any,
) {
if m == nil || m.runtimeEvents == nil {
return
}
if scope.Channel == "" {
scope.Channel = channelName
}
ctx, cancel := context.WithTimeout(context.Background(), channelEventPublishTimeout)
defer cancel()
m.runtimeEvents.Publish(ctx, runtimeevents.Event{
Kind: kind,
Source: runtimeevents.Source{Component: "channel", Name: channelName},
Scope: scope,
Severity: severity,
Payload: payload,
})
}
func (m *Manager) publishOutboundSent(
channelName string,
msg bus.OutboundMessage,
messageIDs []string,
) {
m.publishChannelEvent(
runtimeevents.KindChannelMessageOutboundSent,
channelName,
scopeFromOutboundContext(msg.Context),
runtimeevents.SeverityInfo,
ChannelOutboundPayload{
ContentLen: len([]rune(msg.Content)),
MessageIDs: append([]string(nil), messageIDs...),
ReplyToMessageID: msg.ReplyToMessageID,
},
)
}
func (m *Manager) publishOutboundQueued(
channelName string,
msg bus.OutboundMessage,
) {
m.publishChannelEvent(
runtimeevents.KindChannelMessageOutboundQueued,
channelName,
scopeFromOutboundContext(msg.Context),
runtimeevents.SeverityInfo,
ChannelOutboundPayload{
ContentLen: len([]rune(msg.Content)),
ReplyToMessageID: msg.ReplyToMessageID,
},
)
}
func (m *Manager) publishOutboundFailed(
channelName string,
msg bus.OutboundMessage,
err error,
media bool,
) {
payload := ChannelOutboundPayload{
Media: media,
ContentLen: len([]rune(msg.Content)),
ReplyToMessageID: msg.ReplyToMessageID,
Retries: maxRetries,
}
if err != nil {
payload.Error = err.Error()
}
m.publishChannelEvent(
runtimeevents.KindChannelMessageOutboundFailed,
channelName,
scopeFromOutboundContext(msg.Context),
runtimeevents.SeverityError,
payload,
)
}
func (m *Manager) publishOutboundMediaSent(
channelName string,
msg bus.OutboundMediaMessage,
messageIDs []string,
) {
m.publishChannelEvent(
runtimeevents.KindChannelMessageOutboundSent,
channelName,
scopeFromOutboundContext(msg.Context),
runtimeevents.SeverityInfo,
ChannelOutboundPayload{
Media: true,
MessageIDs: append([]string(nil), messageIDs...),
},
)
}
func (m *Manager) publishOutboundMediaQueued(
channelName string,
msg bus.OutboundMediaMessage,
) {
m.publishChannelEvent(
runtimeevents.KindChannelMessageOutboundQueued,
channelName,
scopeFromOutboundContext(msg.Context),
runtimeevents.SeverityInfo,
ChannelOutboundPayload{Media: true},
)
}
func (m *Manager) publishOutboundMediaFailed(
channelName string,
msg bus.OutboundMediaMessage,
err error,
) {
payload := ChannelOutboundPayload{
Media: true,
Retries: maxRetries,
}
if err != nil {
payload.Error = err.Error()
}
m.publishChannelEvent(
runtimeevents.KindChannelMessageOutboundFailed,
channelName,
scopeFromOutboundContext(msg.Context),
runtimeevents.SeverityError,
payload,
)
}
func scopeFromOutboundContext(ctx bus.InboundContext) runtimeevents.Scope {
return runtimeevents.Scope{
Channel: ctx.Channel,
Account: ctx.Account,
ChatID: ctx.ChatID,
TopicID: ctx.TopicID,
SpaceID: ctx.SpaceID,
SpaceType: ctx.SpaceType,
ChatType: ctx.ChatType,
SenderID: ctx.SenderID,
MessageID: ctx.MessageID,
}
}

View file

@ -23,6 +23,7 @@ import (
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/constants"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/media"
@ -84,6 +85,7 @@ type Manager struct {
channels map[string]Channel channels map[string]Channel
workers map[string]*channelWorker workers map[string]*channelWorker
bus *bus.MessageBus bus *bus.MessageBus
runtimeEvents runtimeevents.Bus
config *config.Config config *config.Config
mediaStore media.MediaStore mediaStore media.MediaStore
dispatchTask *asyncTask dispatchTask *asyncTask
@ -98,6 +100,32 @@ type Manager struct {
channelHashes map[string]string // channel name → config hash channelHashes map[string]string // channel name → config hash
} }
// ManagerOption configures a channel Manager.
type ManagerOption func(*Manager)
// WithRuntimeEvents injects the runtime event bus used for channel observations.
func WithRuntimeEvents(eventBus runtimeevents.Bus) ManagerOption {
return func(m *Manager) {
m.runtimeEvents = eventBus
}
}
// ChannelLifecyclePayload describes channel lifecycle runtime events.
type ChannelLifecyclePayload struct {
Type string `json:"type,omitempty"`
Error string `json:"error,omitempty"`
}
// ChannelOutboundPayload describes channel outbound message runtime events.
type ChannelOutboundPayload struct {
Media bool `json:"media,omitempty"`
ContentLen int `json:"content_len,omitempty"`
MessageIDs []string `json:"message_ids,omitempty"`
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
Error string `json:"error,omitempty"`
Retries int `json:"retries,omitempty"`
}
type toolFeedbackMessageTracker interface { type toolFeedbackMessageTracker interface {
RecordToolFeedbackMessage(chatID, messageID, content string) RecordToolFeedbackMessage(chatID, messageID, content string)
ClearToolFeedbackMessage(chatID string) ClearToolFeedbackMessage(chatID string)
@ -395,7 +423,12 @@ func (m *Manager) preSendMedia(ctx context.Context, name string, msg bus.Outboun
} }
} }
func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.MediaStore) (*Manager, error) { func NewManager(
cfg *config.Config,
messageBus *bus.MessageBus,
store media.MediaStore,
opts ...ManagerOption,
) (*Manager, error) {
m := &Manager{ m := &Manager{
channels: make(map[string]Channel), channels: make(map[string]Channel),
workers: make(map[string]*channelWorker), workers: make(map[string]*channelWorker),
@ -404,6 +437,11 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.Medi
mediaStore: store, mediaStore: store,
channelHashes: make(map[string]string), channelHashes: make(map[string]string),
} }
for _, opt := range opts {
if opt != nil {
opt(m)
}
}
// Register as streaming delegate so the agent loop can obtain streamers // Register as streaming delegate so the agent loop can obtain streamers
messageBus.SetStreamDelegate(m) messageBus.SetStreamDelegate(m)
@ -528,6 +566,13 @@ func (m *Manager) initChannel(typeName, channelName string) {
setter.SetOwner(ch) setter.SetOwner(ch)
} }
m.channels[channelName] = ch m.channels[channelName] = ch
m.publishChannelEvent(
runtimeevents.KindChannelLifecycleInitialized,
channelName,
runtimeevents.Scope{Channel: channelName},
runtimeevents.SeverityInfo,
ChannelLifecyclePayload{Type: typeName},
)
logger.InfoCF("channels", "Channel enabled successfully", map[string]any{ logger.InfoCF("channels", "Channel enabled successfully", map[string]any{
"channel": channelName, "channel": channelName,
"type": typeName, "type": typeName,
@ -673,6 +718,13 @@ func (m *Manager) registerHTTPHandlersLocked() {
func (m *Manager) registerChannelHTTPHandler(name string, ch Channel) { func (m *Manager) registerChannelHTTPHandler(name string, ch Channel) {
if wh, ok := ch.(WebhookHandler); ok { if wh, ok := ch.(WebhookHandler); ok {
m.mux.Handle(wh.WebhookPath(), wh) m.mux.Handle(wh.WebhookPath(), wh)
m.publishChannelEvent(
runtimeevents.KindChannelWebhookRegistered,
name,
runtimeevents.Scope{Channel: name},
runtimeevents.SeverityInfo,
ChannelLifecyclePayload{Type: channelTypeForEvent(m, name)},
)
logger.InfoCF("channels", "Webhook handler registered", map[string]any{ logger.InfoCF("channels", "Webhook handler registered", map[string]any{
"channel": name, "channel": name,
"path": wh.WebhookPath(), "path": wh.WebhookPath(),
@ -692,6 +744,13 @@ func (m *Manager) registerChannelHTTPHandler(name string, ch Channel) {
func (m *Manager) unregisterChannelHTTPHandler(name string, ch Channel) { func (m *Manager) unregisterChannelHTTPHandler(name string, ch Channel) {
if wh, ok := ch.(WebhookHandler); ok { if wh, ok := ch.(WebhookHandler); ok {
m.mux.Unhandle(wh.WebhookPath()) m.mux.Unhandle(wh.WebhookPath())
m.publishChannelEvent(
runtimeevents.KindChannelWebhookUnregistered,
name,
runtimeevents.Scope{Channel: name},
runtimeevents.SeverityInfo,
ChannelLifecyclePayload{Type: channelTypeForEvent(m, name)},
)
logger.InfoCF("channels", "Webhook handler unregistered", map[string]any{ logger.InfoCF("channels", "Webhook handler unregistered", map[string]any{
"channel": name, "channel": name,
"path": wh.WebhookPath(), "path": wh.WebhookPath(),
@ -730,6 +789,13 @@ func (m *Manager) StartAll(ctx context.Context) error {
"channel": name, "channel": name,
"error": err.Error(), "error": err.Error(),
}) })
m.publishChannelEvent(
runtimeevents.KindChannelLifecycleStartFailed,
name,
runtimeevents.Scope{Channel: name},
runtimeevents.SeverityError,
ChannelLifecyclePayload{Type: channelTypeForEvent(m, name), Error: err.Error()},
)
failedStarts = append(failedStarts, fmt.Errorf("channel %s: %w", name, err)) failedStarts = append(failedStarts, fmt.Errorf("channel %s: %w", name, err))
failedNames = append(failedNames, name) failedNames = append(failedNames, name)
continue continue
@ -745,6 +811,13 @@ func (m *Manager) StartAll(ctx context.Context) error {
m.workers[name] = w m.workers[name] = w
go m.runWorker(dispatchCtx, name, w) go m.runWorker(dispatchCtx, name, w)
go m.runMediaWorker(dispatchCtx, name, w) go m.runMediaWorker(dispatchCtx, name, w)
m.publishChannelEvent(
runtimeevents.KindChannelLifecycleStarted,
name,
runtimeevents.Scope{Channel: name},
runtimeevents.SeverityInfo,
ChannelLifecyclePayload{Type: channelType},
)
} }
if len(m.channels) > 0 && len(m.workers) == 0 { if len(m.channels) > 0 && len(m.workers) == 0 {
@ -881,7 +954,15 @@ func (m *Manager) StopAll(ctx context.Context) error {
"channel": name, "channel": name,
"error": err.Error(), "error": err.Error(),
}) })
continue
} }
m.publishChannelEvent(
runtimeevents.KindChannelLifecycleStopped,
name,
runtimeevents.Scope{Channel: name},
runtimeevents.SeverityInfo,
ChannelLifecyclePayload{Type: channelTypeForEvent(m, name)},
)
} }
logger.InfoC("channels", "All channels stopped") logger.InfoC("channels", "All channels stopped")
@ -991,11 +1072,23 @@ func (m *Manager) sendWithRetry(
// Rate limit: wait for token // Rate limit: wait for token
if err := w.limiter.Wait(ctx); err != nil { if err := w.limiter.Wait(ctx); err != nil {
// ctx canceled, shutting down // ctx canceled, shutting down
m.publishChannelEvent(
runtimeevents.KindChannelRateLimited,
name,
scopeFromOutboundContext(msg.Context),
runtimeevents.SeverityWarn,
ChannelOutboundPayload{
ContentLen: len([]rune(msg.Content)),
ReplyToMessageID: msg.ReplyToMessageID,
Error: err.Error(),
},
)
return nil, false return nil, false
} }
// Pre-send: stop typing and try to edit placeholder // Pre-send: stop typing and try to edit placeholder
if msgIDs, handled := m.preSend(ctx, name, msg, w.ch); handled { if msgIDs, handled := m.preSend(ctx, name, msg, w.ch); handled {
m.publishOutboundSent(name, msg, msgIDs)
return msgIDs, true return msgIDs, true
} }
@ -1004,6 +1097,7 @@ func (m *Manager) sendWithRetry(
for attempt := 0; attempt <= maxRetries; attempt++ { for attempt := 0; attempt <= maxRetries; attempt++ {
msgIDs, lastErr = w.ch.Send(ctx, msg) msgIDs, lastErr = w.ch.Send(ctx, msg)
if lastErr == nil { if lastErr == nil {
m.publishOutboundSent(name, msg, msgIDs)
return msgIDs, true return msgIDs, true
} }
@ -1043,6 +1137,7 @@ func (m *Manager) sendWithRetry(
"error": lastErr.Error(), "error": lastErr.Error(),
"retries": maxRetries, "retries": maxRetries,
}) })
m.publishOutboundFailed(name, msg, lastErr, false)
return nil, false return nil, false
} }
@ -1105,6 +1200,7 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool { func(ctx context.Context, w *channelWorker, msg bus.OutboundMessage) bool {
select { select {
case w.queue <- msg: case w.queue <- msg:
m.publishOutboundQueued(outboundMessageChannel(msg), msg)
return true return true
case <-ctx.Done(): case <-ctx.Done():
return false return false
@ -1125,6 +1221,7 @@ func (m *Manager) dispatchOutboundMedia(ctx context.Context) {
func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool { func(ctx context.Context, w *channelWorker, msg bus.OutboundMediaMessage) bool {
select { select {
case w.mediaQueue <- msg: case w.mediaQueue <- msg:
m.publishOutboundMediaQueued(outboundMediaChannel(msg), msg)
return true return true
case <-ctx.Done(): case <-ctx.Done():
return false return false
@ -1174,6 +1271,16 @@ func (m *Manager) sendMediaWithRetry(
// Rate limit: wait for token // Rate limit: wait for token
if err := w.limiter.Wait(ctx); err != nil { if err := w.limiter.Wait(ctx); err != nil {
m.publishChannelEvent(
runtimeevents.KindChannelRateLimited,
name,
scopeFromOutboundContext(msg.Context),
runtimeevents.SeverityWarn,
ChannelOutboundPayload{
Media: true,
Error: err.Error(),
},
)
return nil, err return nil, err
} }
@ -1185,6 +1292,7 @@ func (m *Manager) sendMediaWithRetry(
for attempt := 0; attempt <= maxRetries; attempt++ { for attempt := 0; attempt <= maxRetries; attempt++ {
msgIDs, lastErr = ms.SendMedia(ctx, msg) msgIDs, lastErr = ms.SendMedia(ctx, msg)
if lastErr == nil { if lastErr == nil {
m.publishOutboundMediaSent(name, msg, msgIDs)
return msgIDs, nil return msgIDs, nil
} }
@ -1224,6 +1332,7 @@ func (m *Manager) sendMediaWithRetry(
"error": lastErr.Error(), "error": lastErr.Error(),
"retries": maxRetries, "retries": maxRetries,
}) })
m.publishOutboundMediaFailed(name, msg, lastErr)
return nil, lastErr return nil, lastErr
} }
@ -1361,6 +1470,13 @@ func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error {
"channel": name, "channel": name,
"error": err.Error(), "error": err.Error(),
}) })
m.publishChannelEvent(
runtimeevents.KindChannelLifecycleStartFailed,
name,
runtimeevents.Scope{Channel: name},
runtimeevents.SeverityError,
ChannelLifecyclePayload{Type: channelTypeForEvent(m, name), Error: err.Error()},
)
continue continue
} }
// Lazily create worker only after channel starts successfully // Lazily create worker only after channel starts successfully
@ -1374,6 +1490,13 @@ func (m *Manager) Reload(ctx context.Context, cfg *config.Config) error {
m.workers[name] = w m.workers[name] = w
go m.runWorker(dispatchCtx, name, w) go m.runWorker(dispatchCtx, name, w)
go m.runMediaWorker(dispatchCtx, name, w) go m.runMediaWorker(dispatchCtx, name, w)
m.publishChannelEvent(
runtimeevents.KindChannelLifecycleStarted,
name,
runtimeevents.Scope{Channel: name},
runtimeevents.SeverityInfo,
ChannelLifecyclePayload{Type: channelType},
)
deferFuncs = append(deferFuncs, func() { deferFuncs = append(deferFuncs, func() {
m.RegisterChannel(name, channel) m.RegisterChannel(name, channel)
}) })
@ -1496,6 +1619,7 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten
if wExists && w != nil { if wExists && w != nil {
select { select {
case w.queue <- msg: case w.queue <- msg:
m.publishOutboundQueued(channelName, msg)
return nil return nil
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return ctx.Err()

View file

@ -14,6 +14,7 @@ import (
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
@ -242,6 +243,57 @@ func TestStartAll_PartialFailure_StartsSuccessfulWorkers(t *testing.T) {
} }
} }
func TestStartAllPublishesLifecycleRuntimeEvents(t *testing.T) {
eventBus := runtimeevents.NewBus()
defer func() {
if err := eventBus.Close(); err != nil {
t.Errorf("event bus close failed: %v", err)
}
}()
_, eventsCh, err := eventBus.Channel().SubscribeChan(
t.Context(),
runtimeevents.SubscribeOptions{Name: "channel-lifecycle", Buffer: 4},
)
if err != nil {
t.Fatalf("SubscribeChan failed: %v", err)
}
m := newTestManager()
m.runtimeEvents = eventBus
m.config = &config.Config{Channels: config.ChannelsConfig{}}
m.channels["good"] = &mockChannel{}
m.channels["bad"] = &mockChannel{
startFn: func(_ context.Context) error { return errors.New("bad start") },
}
if err := m.StartAll(t.Context()); err != nil {
t.Fatalf("StartAll() error = %v", err)
}
t.Cleanup(func() {
stopCtx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := m.StopAll(stopCtx); err != nil {
t.Errorf("StopAll() error = %v", err)
}
})
events := []runtimeevents.Event{
receiveChannelRuntimeEvent(t, eventsCh),
receiveChannelRuntimeEvent(t, eventsCh),
}
seen := map[runtimeevents.Kind]runtimeevents.Event{}
for _, evt := range events {
seen[evt.Kind] = evt
}
if evt, ok := seen[runtimeevents.KindChannelLifecycleStarted]; !ok || evt.Scope.Channel != "good" {
t.Fatalf("missing started event for good channel: %+v", events)
}
if evt, ok := seen[runtimeevents.KindChannelLifecycleStartFailed]; !ok || evt.Scope.Channel != "bad" {
t.Fatalf("missing failed event for bad channel: %+v", events)
}
}
func testOutboundMessage(msg bus.OutboundMessage) bus.OutboundMessage { func testOutboundMessage(msg bus.OutboundMessage) bus.OutboundMessage {
if msg.Context.Channel == "" && msg.Context.ChatID == "" { if msg.Context.Channel == "" && msg.Context.ChatID == "" {
msg.Context = bus.NewOutboundContext(msg.Channel, msg.ChatID, msg.ReplyToMessageID) msg.Context = bus.NewOutboundContext(msg.Channel, msg.ChatID, msg.ReplyToMessageID)
@ -256,6 +308,21 @@ func testOutboundMediaMessage(msg bus.OutboundMediaMessage) bus.OutboundMediaMes
return bus.NormalizeOutboundMediaMessage(msg) return bus.NormalizeOutboundMediaMessage(msg)
} }
func receiveChannelRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event {
t.Helper()
select {
case evt, ok := <-ch:
if !ok {
t.Fatal("runtime event channel closed before expected event")
}
return evt
case <-time.After(time.Second):
t.Fatal("timed out waiting for runtime event")
return runtimeevents.Event{}
}
}
func TestSendWithRetry_Success(t *testing.T) { func TestSendWithRetry_Success(t *testing.T) {
m := newTestManager() m := newTestManager()
var callCount int var callCount int
@ -280,6 +347,63 @@ func TestSendWithRetry_Success(t *testing.T) {
} }
} }
func TestSendWithRetryPublishesOutboundRuntimeEvents(t *testing.T) {
eventBus := runtimeevents.NewBus()
defer func() {
if err := eventBus.Close(); err != nil {
t.Errorf("event bus close failed: %v", err)
}
}()
_, eventsCh, err := eventBus.Channel().OfKind(
runtimeevents.KindChannelMessageOutboundSent,
runtimeevents.KindChannelMessageOutboundFailed,
).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "channel-outbound", Buffer: 2})
if err != nil {
t.Fatalf("SubscribeChan failed: %v", err)
}
m := newTestManager()
m.runtimeEvents = eventBus
successWorker := &channelWorker{
ch: &mockChannel{},
limiter: rate.NewLimiter(rate.Inf, 1),
}
m.sendWithRetry(
context.Background(),
"test",
successWorker,
testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "chat-1", Content: "hello"}),
)
sent := receiveChannelRuntimeEvent(t, eventsCh)
if sent.Kind != runtimeevents.KindChannelMessageOutboundSent || sent.Scope.ChatID != "chat-1" {
t.Fatalf("sent event = %+v", sent)
}
failWorker := &channelWorker{
ch: &mockChannel{
sendFn: func(context.Context, bus.OutboundMessage) error {
return fmt.Errorf("send failed: %w", ErrSendFailed)
},
},
limiter: rate.NewLimiter(rate.Inf, 1),
}
m.sendWithRetry(
context.Background(),
"test",
failWorker,
testOutboundMessage(bus.OutboundMessage{Channel: "test", ChatID: "chat-2", Content: "hello"}),
)
failed := receiveChannelRuntimeEvent(t, eventsCh)
if failed.Kind != runtimeevents.KindChannelMessageOutboundFailed || failed.Scope.ChatID != "chat-2" {
t.Fatalf("failed event = %+v", failed)
}
if failed.Severity != runtimeevents.SeverityError {
t.Fatalf("failed severity = %q", failed.Severity)
}
}
func TestSendWithRetry_TemporaryThenSuccess(t *testing.T) { func TestSendWithRetry_TemporaryThenSuccess(t *testing.T) {
m := newTestManager() m := newTestManager()
var callCount int var callCount int

View file

@ -47,12 +47,33 @@ const (
// KindChannelLifecycleStarted is emitted when a channel starts. // KindChannelLifecycleStarted is emitted when a channel starts.
KindChannelLifecycleStarted Kind = "channel.lifecycle.started" KindChannelLifecycleStarted Kind = "channel.lifecycle.started"
// KindChannelLifecycleInitialized is emitted when a channel is initialized.
KindChannelLifecycleInitialized Kind = "channel.lifecycle.initialized"
// KindChannelLifecycleStartFailed is emitted when a channel fails to start. // KindChannelLifecycleStartFailed is emitted when a channel fails to start.
KindChannelLifecycleStartFailed Kind = "channel.lifecycle.start_failed" KindChannelLifecycleStartFailed Kind = "channel.lifecycle.start_failed"
// KindChannelLifecycleStopped is emitted when a channel stops.
KindChannelLifecycleStopped Kind = "channel.lifecycle.stopped"
// KindChannelWebhookRegistered is emitted when a channel webhook is registered.
KindChannelWebhookRegistered Kind = "channel.webhook.registered"
// KindChannelWebhookUnregistered is emitted when a channel webhook is unregistered.
KindChannelWebhookUnregistered Kind = "channel.webhook.unregistered"
// KindChannelMessageOutboundQueued is emitted when an outbound message is queued.
KindChannelMessageOutboundQueued Kind = "channel.message.outbound_queued"
// KindChannelMessageOutboundSent is emitted when an outbound channel message is sent. // KindChannelMessageOutboundSent is emitted when an outbound channel message is sent.
KindChannelMessageOutboundSent Kind = "channel.message.outbound_sent" KindChannelMessageOutboundSent Kind = "channel.message.outbound_sent"
// KindChannelMessageOutboundFailed is emitted when an outbound channel message fails. // KindChannelMessageOutboundFailed is emitted when an outbound channel message fails.
KindChannelMessageOutboundFailed Kind = "channel.message.outbound_failed" KindChannelMessageOutboundFailed Kind = "channel.message.outbound_failed"
// KindChannelRateLimited is emitted when channel rate limiting blocks delivery.
KindChannelRateLimited Kind = "channel.rate_limited"
// KindBusPublishFailed is emitted when message bus publish fails.
KindBusPublishFailed Kind = "bus.publish.failed"
// KindBusCloseStarted is emitted when message bus close starts.
KindBusCloseStarted Kind = "bus.close.started"
// KindBusCloseCompleted is emitted when message bus close completes.
KindBusCloseCompleted Kind = "bus.close.completed"
// KindBusCloseDrained is emitted when message bus close drains buffered messages.
KindBusCloseDrained Kind = "bus.close.drained"
// KindGatewayReloadStarted is emitted when gateway reload starts. // KindGatewayReloadStarted is emitted when gateway reload starts.
KindGatewayReloadStarted Kind = "gateway.reload.started" KindGatewayReloadStarted Kind = "gateway.reload.started"
@ -63,6 +84,14 @@ const (
// KindMCPServerConnected is emitted when an MCP server connects. // KindMCPServerConnected is emitted when an MCP server connects.
KindMCPServerConnected Kind = "mcp.server.connected" KindMCPServerConnected Kind = "mcp.server.connected"
// KindMCPServerConnecting is emitted before connecting to an MCP server.
KindMCPServerConnecting Kind = "mcp.server.connecting"
// KindMCPServerFailed is emitted when an MCP server fails. // KindMCPServerFailed is emitted when an MCP server fails.
KindMCPServerFailed Kind = "mcp.server.failed" KindMCPServerFailed Kind = "mcp.server.failed"
// KindMCPToolDiscovered is emitted when an MCP tool is discovered.
KindMCPToolDiscovered Kind = "mcp.tool.discovered"
// KindMCPToolCallStart is emitted when an MCP tool call starts.
KindMCPToolCallStart Kind = "mcp.tool.call.start"
// KindMCPToolCallEnd is emitted when an MCP tool call ends.
KindMCPToolCallEnd Kind = "mcp.tool.call.end"
) )

46
pkg/gateway/events.go Normal file
View file

@ -0,0 +1,46 @@
package gateway
import (
"context"
"time"
"github.com/sipeed/picoclaw/pkg/agent"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
)
const gatewayEventPublishTimeout = 100 * time.Millisecond
type gatewayReloadPayload struct {
DurationMS int64 `json:"duration_ms,omitempty"`
Error string `json:"error,omitempty"`
}
func publishGatewayReloadEvent(
al *agent.AgentLoop,
kind runtimeevents.Kind,
startedAt time.Time,
err error,
) {
if al == nil || al.RuntimeEventBus() == nil {
return
}
severity := runtimeevents.SeverityInfo
payload := gatewayReloadPayload{}
if !startedAt.IsZero() {
payload.DurationMS = time.Since(startedAt).Milliseconds()
}
if err != nil {
severity = runtimeevents.SeverityError
payload.Error = err.Error()
}
ctx, cancel := context.WithTimeout(context.Background(), gatewayEventPublishTimeout)
defer cancel()
al.RuntimeEventBus().Publish(ctx, runtimeevents.Event{
Kind: kind,
Source: runtimeevents.Source{Component: "gateway"},
Severity: severity,
Payload: payload,
})
}

View file

@ -39,6 +39,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/devices" "github.com/sipeed/picoclaw/pkg/devices"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/health" "github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/heartbeat"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
@ -197,6 +198,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
msgBus.SetEventPublisher(agentLoop.RuntimeEventBus())
fmt.Println("\n📦 Agent Status:") fmt.Println("\n📦 Agent Status:")
startupInfo := agentLoop.GetStartupInfo() startupInfo := agentLoop.GetStartupInfo()
@ -312,10 +314,20 @@ func executeReload(
msgBus *bus.MessageBus, msgBus *bus.MessageBus,
allowEmptyStartup bool, allowEmptyStartup bool,
debug bool, debug bool,
) error { ) (err error) {
startedAt := time.Now()
publishGatewayReloadEvent(agentLoop, runtimeevents.KindGatewayReloadStarted, startedAt, nil)
defer runningServices.reloading.Store(false) defer runningServices.reloading.Store(false)
defer func() {
if err != nil {
publishGatewayReloadEvent(agentLoop, runtimeevents.KindGatewayReloadFailed, startedAt, err)
return
}
publishGatewayReloadEvent(agentLoop, runtimeevents.KindGatewayReloadCompleted, startedAt, nil)
}()
return handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup, debug) err = handleConfigReload(ctx, agentLoop, newCfg, provider, runningServices, msgBus, allowEmptyStartup, debug)
return err
} }
func createStartupProvider( func createStartupProvider(
@ -383,7 +395,12 @@ func setupAndStartServices(
fms.Start() fms.Start()
} }
runningServices.ChannelManager, err = channels.NewManager(cfg, msgBus, runningServices.MediaStore) runningServices.ChannelManager, err = channels.NewManager(
cfg,
msgBus,
runningServices.MediaStore,
channels.WithRuntimeEvents(agentLoop.RuntimeEventBus()),
)
if err != nil { if err != nil {
if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok { if fms, ok := runningServices.MediaStore.(*media.FileMediaStore); ok {
fms.Stop() fms.Stop()

81
pkg/mcp/events.go Normal file
View file

@ -0,0 +1,81 @@
package mcp
import (
"context"
"time"
"github.com/sipeed/picoclaw/pkg/config"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
)
const mcpEventPublishTimeout = 100 * time.Millisecond
func (m *Manager) publishServerEvent(
kind runtimeevents.Kind,
serverName string,
cfg config.MCPServerConfig,
toolCount int,
err error,
) {
if m == nil || m.runtimeEvents == nil {
return
}
severity := runtimeevents.SeverityInfo
if err != nil {
severity = runtimeevents.SeverityError
}
payload := ServerEventPayload{
Server: serverName,
Type: mcpTransportType(cfg),
URL: cfg.URL,
Command: cfg.Command,
ToolCount: toolCount,
}
if err != nil {
payload.Error = err.Error()
}
ctx, cancel := context.WithTimeout(context.Background(), mcpEventPublishTimeout)
defer cancel()
m.runtimeEvents.Publish(ctx, runtimeevents.Event{
Kind: kind,
Source: runtimeevents.Source{Component: "mcp", Name: serverName},
Severity: severity,
Payload: payload,
})
}
func (m *Manager) publishToolDiscovered(serverName string, cfg config.MCPServerConfig, toolName string) {
if m == nil || m.runtimeEvents == nil {
return
}
payload := ServerEventPayload{
Server: serverName,
Type: mcpTransportType(cfg),
URL: cfg.URL,
Command: cfg.Command,
Tool: toolName,
}
ctx, cancel := context.WithTimeout(context.Background(), mcpEventPublishTimeout)
defer cancel()
m.runtimeEvents.Publish(ctx, runtimeevents.Event{
Kind: runtimeevents.KindMCPToolDiscovered,
Source: runtimeevents.Source{Component: "mcp", Name: serverName},
Severity: runtimeevents.SeverityInfo,
Payload: payload,
})
}
func mcpTransportType(cfg config.MCPServerConfig) string {
if cfg.Type != "" {
return cfg.Type
}
if cfg.URL != "" {
return "sse"
}
if cfg.Command != "" {
return "stdio"
}
return ""
}

View file

@ -16,6 +16,7 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
) )
@ -127,19 +128,47 @@ type ServerConnection struct {
// Manager manages multiple MCP server connections // Manager manages multiple MCP server connections
type Manager struct { type Manager struct {
servers map[string]*ServerConnection servers map[string]*ServerConnection
mu sync.RWMutex runtimeEvents runtimeevents.Bus
closed atomic.Bool // changed from bool to atomic.Bool to avoid TOCTOU race mu sync.RWMutex
wg sync.WaitGroup // tracks in-flight CallTool calls closed atomic.Bool // changed from bool to atomic.Bool to avoid TOCTOU race
wg sync.WaitGroup // tracks in-flight CallTool calls
} }
var connectServerFunc = connectServer var connectServerFunc = connectServer
// ManagerOption configures an MCP manager.
type ManagerOption func(*Manager)
// WithRuntimeEvents injects the runtime event bus used for MCP observations.
func WithRuntimeEvents(eventBus runtimeevents.Bus) ManagerOption {
return func(m *Manager) {
m.runtimeEvents = eventBus
}
}
// ServerEventPayload describes MCP server connection events.
type ServerEventPayload struct {
Server string `json:"server"`
Type string `json:"type,omitempty"`
URL string `json:"url,omitempty"`
Command string `json:"command,omitempty"`
Tool string `json:"tool,omitempty"`
ToolCount int `json:"tool_count,omitempty"`
Error string `json:"error,omitempty"`
}
// NewManager creates a new MCP manager // NewManager creates a new MCP manager
func NewManager() *Manager { func NewManager(opts ...ManagerOption) *Manager {
return &Manager{ m := &Manager{
servers: make(map[string]*ServerConnection), servers: make(map[string]*ServerConnection),
} }
for _, opt := range opts {
if opt != nil {
opt(m)
}
}
return m
} }
// LoadFromConfig loads MCP servers from configuration // LoadFromConfig loads MCP servers from configuration
@ -264,8 +293,10 @@ func (m *Manager) ConnectServer(
name string, name string,
cfg config.MCPServerConfig, cfg config.MCPServerConfig,
) error { ) error {
m.publishServerEvent(runtimeevents.KindMCPServerConnecting, name, cfg, 0, nil)
conn, err := connectServerFunc(ctx, name, cfg) conn, err := connectServerFunc(ctx, name, cfg)
if err != nil { if err != nil {
m.publishServerEvent(runtimeevents.KindMCPServerFailed, name, cfg, 0, err)
return err return err
} }
@ -274,10 +305,19 @@ func (m *Manager) ConnectServer(
if m.closed.Load() { if m.closed.Load() {
_ = conn.Session.Close() _ = conn.Session.Close()
m.publishServerEvent(runtimeevents.KindMCPServerFailed, name, cfg, 0, fmt.Errorf("manager is closed"))
return fmt.Errorf("manager is closed") return fmt.Errorf("manager is closed")
} }
m.servers[name] = conn m.servers[name] = conn
for _, tool := range conn.Tools {
toolName := ""
if tool != nil {
toolName = tool.Name
}
m.publishToolDiscovered(name, cfg, toolName)
}
m.publishServerEvent(runtimeevents.KindMCPServerConnected, name, cfg, len(conn.Tools), nil)
return nil return nil
} }

View file

@ -10,11 +10,13 @@ import (
"strings" "strings"
"sync" "sync"
"testing" "testing"
"time"
"github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/jsonrpc"
sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
) )
func TestLoadEnvFile(t *testing.T) { func TestLoadEnvFile(t *testing.T) {
@ -248,6 +250,87 @@ func TestNewManager_InitialState(t *testing.T) {
} }
} }
func TestConnectServerPublishesRuntimeEvents(t *testing.T) {
originalConnectServerFunc := connectServerFunc
t.Cleanup(func() {
connectServerFunc = originalConnectServerFunc
})
eventBus := runtimeevents.NewBus()
defer func() {
if err := eventBus.Close(); err != nil {
t.Errorf("event bus close failed: %v", err)
}
}()
_, eventsCh, err := eventBus.Channel().OfKind(
runtimeevents.KindMCPServerConnected,
runtimeevents.KindMCPServerFailed,
).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "mcp-events", Buffer: 2})
if err != nil {
t.Fatalf("SubscribeChan failed: %v", err)
}
connectServerFunc = func(
_ context.Context,
name string,
cfg config.MCPServerConfig,
) (*ServerConnection, error) {
if name == "bad" {
return nil, fmt.Errorf("connect failed")
}
return &ServerConnection{
Name: name,
Config: cfg,
Tools: []*sdkmcp.Tool{{Name: "echo"}},
}, nil
}
mgr := NewManager(WithRuntimeEvents(eventBus))
err = mgr.ConnectServer(context.Background(), "good", config.MCPServerConfig{
Type: "stdio",
Command: "echo",
})
if err != nil {
t.Fatalf("ConnectServer(good) error = %v", err)
}
connected := receiveMCPRuntimeEvent(t, eventsCh)
if connected.Kind != runtimeevents.KindMCPServerConnected ||
connected.Source.Name != "good" ||
connected.Severity != runtimeevents.SeverityInfo {
t.Fatalf("connected event = %+v", connected)
}
err = mgr.ConnectServer(context.Background(), "bad", config.MCPServerConfig{
Type: "stdio",
Command: "echo",
})
if err == nil {
t.Fatal("expected ConnectServer(bad) to fail")
}
failed := receiveMCPRuntimeEvent(t, eventsCh)
if failed.Kind != runtimeevents.KindMCPServerFailed ||
failed.Source.Name != "bad" ||
failed.Severity != runtimeevents.SeverityError {
t.Fatalf("failed event = %+v", failed)
}
}
func receiveMCPRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event {
t.Helper()
select {
case evt, ok := <-ch:
if !ok {
t.Fatal("runtime event channel closed before expected event")
}
return evt
case <-time.After(time.Second):
t.Fatal("timed out waiting for runtime event")
return runtimeevents.Event{}
}
}
func TestLoadFromMCPConfig_DisabledOrEmptyServers(t *testing.T) { func TestLoadFromMCPConfig_DisabledOrEmptyServers(t *testing.T) {
mgr := NewManager() mgr := NewManager()

View file

@ -13,6 +13,7 @@ import (
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/media"
toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
@ -36,6 +37,16 @@ type MCPTool struct {
mediaStore media.MediaStore mediaStore media.MediaStore
workspace string workspace string
maxInlineTextRunes int maxInlineTextRunes int
runtimeEvents runtimeevents.Bus
}
// MCPToolCallPayload describes MCP tool execution runtime events.
type MCPToolCallPayload struct {
Server string `json:"server"`
Tool string `json:"tool"`
DurationMS int64 `json:"duration_ms,omitempty"`
IsError bool `json:"is_error,omitempty"`
Error string `json:"error,omitempty"`
} }
// NewMCPTool creates a new MCP tool wrapper // NewMCPTool creates a new MCP tool wrapper
@ -62,6 +73,11 @@ func (t *MCPTool) SetMaxInlineTextRunes(limit int) {
} }
} }
// SetEventPublisher injects the runtime event bus used for MCP tool observations.
func (t *MCPTool) SetEventPublisher(eventBus runtimeevents.Bus) {
t.runtimeEvents = eventBus
}
const maxMCPInlineTextRunes = 16 * 1024 const maxMCPInlineTextRunes = 16 * 1024
// sanitizeIdentifierComponent normalizes a string so it can be safely used // sanitizeIdentifierComponent normalizes a string so it can be safely used
@ -237,26 +253,74 @@ func (t *MCPTool) Parameters() map[string]any {
// Execute executes the MCP tool // Execute executes the MCP tool
func (t *MCPTool) Execute(ctx context.Context, args map[string]any) *ToolResult { func (t *MCPTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
startedAt := time.Now()
t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallStart, startedAt, false, "")
result, err := t.manager.CallTool(ctx, t.serverName, t.tool.Name, args) result, err := t.manager.CallTool(ctx, t.serverName, t.tool.Name, args)
if err != nil { if err != nil {
t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, err.Error())
return ErrorResult(fmt.Sprintf("MCP tool execution failed: %v", err)).WithError(err) return ErrorResult(fmt.Sprintf("MCP tool execution failed: %v", err)).WithError(err)
} }
if result == nil { if result == nil {
nilErr := fmt.Errorf("MCP tool returned nil result without error") nilErr := fmt.Errorf("MCP tool returned nil result without error")
t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, nilErr.Error())
return ErrorResult("MCP tool execution failed: nil result").WithError(nilErr) return ErrorResult("MCP tool execution failed: nil result").WithError(nilErr)
} }
// Handle error result from server // Handle error result from server
if result.IsError { if result.IsError {
errMsg := extractContentText(result.Content) errMsg := extractContentText(result.Content)
t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, true, errMsg)
return ErrorResult(fmt.Sprintf("MCP tool returned error: %s", errMsg)). return ErrorResult(fmt.Sprintf("MCP tool returned error: %s", errMsg)).
WithError(fmt.Errorf("MCP tool error: %s", errMsg)) WithError(fmt.Errorf("MCP tool error: %s", errMsg))
} }
t.publishRuntimeEvent(ctx, runtimeevents.KindMCPToolCallEnd, startedAt, false, "")
return t.normalizeResultContent(ctx, result.Content) return t.normalizeResultContent(ctx, result.Content)
} }
func (t *MCPTool) publishRuntimeEvent(
ctx context.Context,
kind runtimeevents.Kind,
startedAt time.Time,
isError bool,
errMsg string,
) {
if t == nil || t.runtimeEvents == nil {
return
}
scope := runtimeevents.Scope{
AgentID: toolshared.ToolAgentID(ctx),
SessionKey: toolshared.ToolSessionKey(ctx),
Channel: toolshared.ToolChannel(ctx),
ChatID: toolshared.ToolChatID(ctx),
MessageID: toolshared.ToolMessageID(ctx),
}
payload := MCPToolCallPayload{
Server: t.serverName,
Tool: t.tool.Name,
DurationMS: time.Since(startedAt).Milliseconds(),
IsError: isError,
Error: errMsg,
}
severity := runtimeevents.SeverityInfo
if isError {
severity = runtimeevents.SeverityError
}
publishCtx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
t.runtimeEvents.Publish(publishCtx, runtimeevents.Event{
Kind: kind,
Source: runtimeevents.Source{Component: "mcp", Name: t.serverName},
Scope: scope,
Severity: severity,
Payload: payload,
})
}
// extractContentText extracts text from MCP content array // extractContentText extracts text from MCP content array
func extractContentText(content []mcp.Content) string { func extractContentText(content []mcp.Content) string {
var parts []string var parts []string

View file

@ -7,9 +7,11 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
runtimeevents "github.com/sipeed/picoclaw/pkg/events"
"github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/media"
toolshared "github.com/sipeed/picoclaw/pkg/tools/shared" toolshared "github.com/sipeed/picoclaw/pkg/tools/shared"
) )
@ -299,6 +301,72 @@ func TestMCPTool_Execute_Success(t *testing.T) {
} }
} }
func TestMCPTool_Execute_PublishesRuntimeEvents(t *testing.T) {
eventBus := runtimeevents.NewBus()
defer func() {
if err := eventBus.Close(); err != nil {
t.Errorf("event bus close failed: %v", err)
}
}()
_, eventsCh, err := eventBus.Channel().OfKind(
runtimeevents.KindMCPToolCallStart,
runtimeevents.KindMCPToolCallEnd,
).SubscribeChan(t.Context(), runtimeevents.SubscribeOptions{Name: "mcp-tool-events", Buffer: 2})
if err != nil {
t.Fatalf("SubscribeChan failed: %v", err)
}
manager := &MockMCPManager{}
mcpTool := NewMCPTool(manager, "github", &mcp.Tool{Name: "search_repos"})
mcpTool.SetEventPublisher(eventBus)
ctx := toolshared.WithToolContext(context.Background(), "telegram", "chat-1")
ctx = toolshared.WithToolMessageContext(ctx, "msg-1", "")
ctx = toolshared.WithToolSessionContext(ctx, "main", "session-1", nil)
result := mcpTool.Execute(ctx, map[string]any{"query": "picoclaw"})
if result == nil || result.IsError {
t.Fatalf("Execute result = %+v", result)
}
started := receiveMCPToolRuntimeEvent(t, eventsCh)
if started.Kind != runtimeevents.KindMCPToolCallStart ||
started.Scope.AgentID != "main" ||
started.Scope.SessionKey != "session-1" ||
started.Scope.Channel != "telegram" ||
started.Scope.ChatID != "chat-1" ||
started.Scope.MessageID != "msg-1" {
t.Fatalf("started event = %+v", started)
}
ended := receiveMCPToolRuntimeEvent(t, eventsCh)
if ended.Kind != runtimeevents.KindMCPToolCallEnd || ended.Severity != runtimeevents.SeverityInfo {
t.Fatalf("ended event = %+v", ended)
}
payload, ok := ended.Payload.(MCPToolCallPayload)
if !ok {
t.Fatalf("ended payload = %T, want MCPToolCallPayload", ended.Payload)
}
if payload.Server != "github" || payload.Tool != "search_repos" || payload.IsError {
t.Fatalf("ended payload = %+v", payload)
}
}
func receiveMCPToolRuntimeEvent(t *testing.T, ch <-chan runtimeevents.Event) runtimeevents.Event {
t.Helper()
select {
case evt, ok := <-ch:
if !ok {
t.Fatal("runtime event channel closed before expected event")
}
return evt
case <-time.After(time.Second):
t.Fatal("timed out waiting for runtime event")
return runtimeevents.Event{}
}
}
// TestMCPTool_Execute_ManagerError tests execution when manager returns error // TestMCPTool_Execute_ManagerError tests execution when manager returns error
func TestMCPTool_Execute_ManagerError(t *testing.T) { func TestMCPTool_Execute_ManagerError(t *testing.T) {
manager := &MockMCPManager{ manager := &MockMCPManager{