-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcodec_openclaw.go
More file actions
350 lines (334 loc) · 10.6 KB
/
Copy pathcodec_openclaw.go
File metadata and controls
350 lines (334 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
package agenthooks
import (
"encoding/json"
"errors"
"strings"
"time"
)
// OpenClaw's Gateway loads plugins in-process (api.on typed hooks); like
// OpenCode there is no spawned-process hook protocol. The generated shim
// plugin (install/render_openclaw.go) proxies hook firings to this binary
// over NDJSON stdio: request {seq, hook, event, ctx} -> response
// {seq, output?, error?}. Unlike OpenCode's mutable-output merge, the reply
// output is returned verbatim as the hook handler's return value — OpenClaw
// collects returns ({block, blockReason, requireApproval, params} on
// before_tool_call; a {outcome} gate decision on before_agent_run).
//
// Payload shapes verified against OpenClaw 2026.6.34 (see quirks #34–#37).
type openclawFrame struct {
Seq int64 `json:"seq"`
Hook string `json:"hook"`
Event json.RawMessage `json:"event"`
Ctx json.RawMessage `json:"ctx"`
// TimeoutMS is the shim's deadline for this gate frame (per-hook, from
// the manifest's blocking spec). The serve loop bounds the handler with
// it so the daemon stops working as soon as the shim has given up.
TimeoutMS int64 `json:"timeoutMs,omitempty"`
}
type openclawReply struct {
Seq int64 `json:"seq"`
Output map[string]any `json:"output,omitempty"`
Error string `json:"error,omitempty"`
}
// openclawServeState carries the per-connection context the frames don't:
// conversation-scope hooks report workspaceDir/model, tool-scope hooks don't,
// and a deny decision must flip the corresponding after_tool_call (which
// OpenClaw still fires, carrying the block text as the result — quirk #37)
// from success to blocked.
type openclawServeState struct {
cwdBySession map[string]string
modelBySession map[string]string
blockedCalls map[string]string // toolCallId -> block reason
}
func newOpenclawServeState() *openclawServeState {
return &openclawServeState{
cwdBySession: map[string]string{},
modelBySession: map[string]string{},
blockedCalls: map[string]string{},
}
}
func openclawKind(hook string) EventKind {
switch hook {
case "before_tool_call":
return KindToolPre
case "after_tool_call":
return KindToolPost
case "before_agent_run":
return KindPromptSubmitted
case "session_start":
return KindSessionStart
case "session_end":
return KindSessionEnd
case "agent_end":
return KindStop
case "subagent_spawned":
return KindSubagentStart
case "subagent_ended":
return KindSubagentStop
case "llm_input":
return KindModelRequest
case "llm_output":
return KindModelResponse
case "before_compaction":
return KindCompactPre
case "after_compaction":
return KindCompactPost
}
return KindOther
}
// openclawCtx is the shared hook-context shape. Conversation-scope hooks
// carry workspaceDir/modelId; tool-scope hooks carry toolCallId; every
// agent-scope hook carries runId/sessionKey/sessionId.
type openclawCtx struct {
AgentID string `json:"agentId"`
SessionKey string `json:"sessionKey"`
SessionID string `json:"sessionId"`
RunID string `json:"runId"`
WorkspaceDir string `json:"workspaceDir"`
ModelID string `json:"modelId"`
ToolCallID string `json:"toolCallId"`
ToolName string `json:"toolName"`
}
// decodeOpenClawLine decodes one NDJSON frame without serve-loop state (the
// stateless run-mode path and shape-detection fallback).
func decodeOpenClawLine(v Variant, conf DetectionConfidence, now time.Time, line []byte) (any, error) {
var fr openclawFrame
if err := json.Unmarshal(line, &fr); err != nil {
return nil, err
}
return decodeOpenClawFrame(v, conf, now, &fr, line, nil)
}
func decodeOpenClawFrame(v Variant, conf DetectionConfidence, now time.Time, fr *openclawFrame, raw []byte, st *openclawServeState) (any, error) {
if fr.Hook == "" {
return nil, errors.New("agenthooks: openclaw frame missing hook name")
}
var cx openclawCtx
_ = json.Unmarshal(fr.Ctx, &cx) // ctx shape varies per hook; best-effort probe
sessionID := cx.SessionID
if sessionID == "" {
sessionID = cx.SessionKey
}
if st != nil {
if cx.WorkspaceDir != "" {
st.cwdBySession[sessionID] = cx.WorkspaceDir
}
if cx.ModelID != "" {
st.modelBySession[sessionID] = cx.ModelID
}
if cx.WorkspaceDir == "" {
cx.WorkspaceDir = st.cwdBySession[sessionID]
}
if cx.ModelID == "" {
cx.ModelID = st.modelBySession[sessionID]
}
}
kind := openclawKind(fr.Hook)
base := Event{
Provider: ProviderOpenClaw,
Variant: v,
NativeName: fr.Hook,
Kind: kind,
Time: now,
DetectionConfidence: conf,
Session: SessionInfo{
ID: sessionID,
TurnID: cx.RunID,
CWD: cx.WorkspaceDir,
WorkspaceRoots: rootsFor(cx.WorkspaceDir),
Model: cx.ModelID,
},
Raw: json.RawMessage(raw),
}
switch kind {
case KindToolPre:
var in struct {
ToolName string `json:"toolName"`
Params json.RawMessage `json:"params"`
RunID string `json:"runId"`
ToolCallID string `json:"toolCallId"`
}
_ = json.Unmarshal(fr.Event, &in)
if base.Session.TurnID == "" {
base.Session.TurnID = in.RunID
}
return &ToolPreEvent{Event: base, Tool: makeToolCall(base.Session, in.ToolName, in.ToolCallID, in.Params, in.Params)}, nil
case KindToolPost:
var in struct {
ToolName string `json:"toolName"`
Params json.RawMessage `json:"params"`
RunID string `json:"runId"`
ToolCallID string `json:"toolCallId"`
Result json.RawMessage `json:"result"`
Error string `json:"error"`
DurationMS *float64 `json:"durationMs"`
}
_ = json.Unmarshal(fr.Event, &in)
if base.Session.TurnID == "" {
base.Session.TurnID = in.RunID
}
errMsg := in.Error
if errMsg == "" && st != nil {
if reason, ok := st.blockedCalls[in.ToolCallID]; ok {
// OpenClaw fires after_tool_call for a call this very serve
// session denied, with the block text as the result (quirk
// #37); report it as a failure, not a completion.
errMsg = "blocked: " + reason
delete(st.blockedCalls, in.ToolCallID)
}
}
if errMsg != "" {
base.Kind = KindToolError
}
return &ToolPostEvent{
Event: base,
Tool: makeToolCall(base.Session, in.ToolName, in.ToolCallID, in.Params, in.Params),
Output: in.Result,
Failed: errMsg != "",
Error: errMsg,
DurationMS: in.DurationMS,
}, nil
case KindPromptSubmitted:
var in struct {
Prompt string `json:"prompt"`
}
_ = json.Unmarshal(fr.Event, &in)
return &PromptEvent{Event: base, Prompt: in.Prompt}, nil
case KindStop, KindSubagentStop:
var in struct {
RunID string `json:"runId"`
Success bool `json:"success"`
Error string `json:"error"`
DurationMS *int `json:"durationMs"`
// FinalMessage and Usage are spliced into the agent_end frame by
// the shim from its cached llm_output for the same runId: agent_end
// itself carries only messages/success/duration.
FinalMessage string `json:"finalMessage"`
Usage *struct {
Input *int `json:"input"`
Output *int `json:"output"`
CacheRead *int `json:"cacheRead"`
CacheWrite *int `json:"cacheWrite"`
} `json:"usage"`
}
_ = json.Unmarshal(fr.Event, &in)
if base.Session.TurnID == "" {
base.Session.TurnID = in.RunID
}
var usage *Usage
if in.Usage != nil {
usage = &Usage{
InputTokens: in.Usage.Input,
OutputTokens: in.Usage.Output,
CacheReadTokens: in.Usage.CacheRead,
CacheWriteTokens: in.Usage.CacheWrite,
}
}
if usage != nil && in.Error != "" {
usage.Status = "error"
}
return &StopEvent{Event: base, FinalMessage: in.FinalMessage, Usage: usage}, nil
case KindSessionStart:
var in struct {
SessionID string `json:"sessionId"`
SessionKey string `json:"sessionKey"`
ResumedFrom string `json:"resumedFrom"`
}
_ = json.Unmarshal(fr.Event, &in)
if base.Session.ID == "" {
base.Session.ID = in.SessionID
}
source := "startup"
if in.ResumedFrom != "" {
source = "resume"
}
return &SessionStartEvent{Event: base, Source: source}, nil
case KindSessionEnd:
var in struct {
SessionID string `json:"sessionId"`
Reason string `json:"reason"`
}
_ = json.Unmarshal(fr.Event, &in)
if base.Session.ID == "" {
base.Session.ID = in.SessionID
}
return &SessionEndEvent{Event: base, Reason: in.Reason}, nil
case KindSubagentStart:
var in struct {
ChildSessionKey string `json:"childSessionKey"`
AgentID string `json:"agentId"`
}
_ = json.Unmarshal(fr.Event, &in)
ev := &SubagentStartEvent{Event: base}
ev.Agent = &AgentInfo{ID: in.ChildSessionKey, Type: in.AgentID}
return ev, nil
case KindCompactPre, KindCompactPost:
return &CompactEvent{Event: base}, nil
case KindModelRequest, KindModelResponse:
return &ModelEvent{Event: base}, nil
}
ev := base
return &ev, nil
}
// encodeOpenClawReply builds the shim response frame (seq is filled by the
// serve loop). Output is the hook handler's return value: before_tool_call
// understands {block, blockReason, requireApproval, params}; before_agent_run
// understands the gate decision {outcome, reason, message}. Every decision
// the capability matrix admits is expressible, so encoding cannot fail.
func encodeOpenClawReply(base *Event, d decisionCore, st *openclawServeState, toolCallID string) *openclawReply {
reply := &openclawReply{}
set := func(k string, v any) {
if reply.Output == nil {
reply.Output = map[string]any{}
}
reply.Output[k] = v
}
switch base.Kind {
case KindToolPre:
switch d.kind {
case DecisionDeny:
reason := d.reason
if reason == "" {
reason = "blocked by agenthooks handler"
}
set("block", true)
set("blockReason", reason)
if st != nil && toolCallID != "" {
st.blockedCalls[toolCallID] = reason
}
case DecisionAsk:
reason := d.reason
if reason == "" {
reason = "Approval required"
}
// Headless gateways resolve requireApproval by timing out
// (verified: no hang); timeoutBehavior deny keeps ask-shaped
// decisions fail-safe there.
set("requireApproval", map[string]any{
"title": firstLine(reason),
"description": reason,
"timeoutMs": 60_000,
"timeoutBehavior": "deny",
})
}
if d.hasUpdatedInput {
set("params", d.updatedInput)
}
case KindPromptSubmitted:
if d.kind == DecisionBlockPrompt {
reason := d.reason
if reason == "" {
reason = "blocked by agenthooks handler"
}
set("outcome", "block")
set("reason", reason)
set("message", reason)
}
}
return reply
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}