Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion agent-eval/suite/testcases.json
Original file line number Diff line number Diff line change
Expand Up @@ -961,6 +961,52 @@
"generate": true
}
}
},
{
"id": "hook_pretooluse_deny",
"title": "PreToolUse hook denies a write and blocks the file",
"category": "hook",
"tier": "hook",
"prompt": "Create a file named forbidden.txt containing the text nope. If a tool call is blocked by policy, stop and briefly explain instead of working around it.",
"fixtures": {},
"home_fixtures": {
".jcode/hooks.json": "{\"hooks\":{\"PreToolUse\":[{\"matcher\":\"write\",\"hooks\":[{\"type\":\"command\",\"command\":\"echo blocked-by-policy >&2; exit 2\"}]}]}}"
},
"timeout": 150,
"expect_tool_use": true,
"oracles": [
{
"type": "file_absent",
"path": "forbidden.txt"
},
{
"type": "bounded_tool_calls",
"max": 8
}
]
},
{
"id": "hook_posttooluse_side_effect",
"title": "PostToolUse hook runs after a successful write",
"category": "hook",
"tier": "hook",
"prompt": "Create a file named note.txt with the exact contents hi and no trailing newline. Then you are done.",
"fixtures": {},
"home_fixtures": {
".jcode/hooks.json": "{\"hooks\":{\"PostToolUse\":[{\"matcher\":\"write\",\"hooks\":[{\"type\":\"command\",\"command\":\"echo fired > hook-ran.txt\"}]}]}}"
},
"timeout": 150,
"expect_tool_use": true,
"oracles": [
{
"type": "file_exists",
"path": "note.txt"
},
{
"type": "file_exists",
"path": "hook-ran.txt"
}
]
}
]
}
}
493 changes: 493 additions & 0 deletions internal-doc/hooks-design.md

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,17 @@ func NewAgent(
handlers []adk.ChatModelAgentMiddleware,
) (*adk.ChatModelAgent, error) {
// Handler order is outermost → innermost: tracing middlewares first, then the
// caller's handlers, then approval + safe-tool-error innermost so that
// summarization/reduction see the raw tool output first.
// caller's handlers, then the hook + approval + safe-tool-error stack innermost
// so that summarization/reduction see the raw tool output first.
enhanced := append([]adk.ChatModelAgentMiddleware{}, middlewares...)
enhanced = append(enhanced, handlers...)
// PreToolUse hook sits OUTSIDE approval: it can deny, rewrite args, or mark the
// call pre-approved (so approval skips its prompt) before the gate runs.
enhanced = append(enhanced, newPreHookMiddleware())
enhanced = append(enhanced, newApprovalMiddleware(approvalFunc))
// PostToolUse hook sits INSIDE approval, wrapping the raw tool, so it sees the
// true execution error and can rewrite the result.
enhanced = append(enhanced, newPostHookMiddleware())
// Innermost: memory usage accounting observes approved executions only
// and sees raw endpoint errors (a failed read is not memory usage).
enhanced = append(enhanced, memory.NewUsageMiddleware())
Expand Down
125 changes: 125 additions & 0 deletions internal/agent/hook_middleware.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package agent

import (
"context"
"encoding/json"

"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/components/tool"

"github.com/cnjack/jcode/internal/hooks"
)

// hookToolMiddleware fires the tool-related hooks around a tool invocation. It is
// inserted twice into the chain (see NewAgent):
//
// - pre (post=false): sits OUTSIDE the approval middleware. It runs PreToolUse,
// which can deny the call, rewrite its arguments, inject context, or mark the
// call pre-approved so the approval gate skips its user prompt.
// - post (post=true): sits INSIDE the approval middleware, wrapping the raw
// tool. It sees the real execution error (the approval layer folds errors into
// strings), so it can distinguish PostToolUse from PostToolUseFailure and
// rewrite the result.
//
// The dispatcher is read from the context (injected by the command surface), so a
// session with no hooks configured makes these middlewares free no-ops.
type hookToolMiddleware struct {
*adk.BaseChatModelAgentMiddleware
post bool
}

func newPreHookMiddleware() adk.ChatModelAgentMiddleware {
return &hookToolMiddleware{BaseChatModelAgentMiddleware: &adk.BaseChatModelAgentMiddleware{}, post: false}
}

func newPostHookMiddleware() adk.ChatModelAgentMiddleware {
return &hookToolMiddleware{BaseChatModelAgentMiddleware: &adk.BaseChatModelAgentMiddleware{}, post: true}
}

func (m *hookToolMiddleware) WrapInvokableToolCall(
ctx context.Context,
endpoint adk.InvokableToolCallEndpoint,
tCtx *adk.ToolContext,
) (adk.InvokableToolCallEndpoint, error) {
if m.post {
return m.wrapPost(endpoint, tCtx), nil
}
return m.wrapPre(endpoint, tCtx), nil
}

// wrapPre handles PreToolUse.
func (m *hookToolMiddleware) wrapPre(endpoint adk.InvokableToolCallEndpoint, tCtx *adk.ToolContext) adk.InvokableToolCallEndpoint {
return func(ctx context.Context, args string, opts ...tool.Option) (string, error) {
disp := hooks.DispatcherFromContext(ctx)
if !disp.Configured(hooks.PreToolUse) {
return endpoint(ctx, args, opts...)
}
dec := disp.Fire(ctx, hooks.PreToolUse, hooks.Payload{
ToolName: tCtx.Name,
ToolInput: json.RawMessage(args),
})
if dec.Denied() {
return hookDenyMessage(dec.Reason), nil
}
if len(dec.UpdatedInput) > 0 {
args = string(dec.UpdatedInput)
}
if dec.Permission == hooks.PermAllow {
// Let the inner approval gate skip its user prompt for this call.
ctx = hooks.WithPreApproved(ctx)
}
result, err := endpoint(ctx, args, opts...)
if dec.AdditionalContext != "" {
result = appendHookContext(result, dec.AdditionalContext)
}
return result, err
}
}

// wrapPost handles PostToolUse / PostToolUseFailure.
func (m *hookToolMiddleware) wrapPost(endpoint adk.InvokableToolCallEndpoint, tCtx *adk.ToolContext) adk.InvokableToolCallEndpoint {
return func(ctx context.Context, args string, opts ...tool.Option) (string, error) {
result, err := endpoint(ctx, args, opts...)

event := hooks.PostToolUse
if err != nil {
event = hooks.PostToolUseFailure
}
disp := hooks.DispatcherFromContext(ctx)
if !disp.Configured(event) {
return result, err
}
dec := disp.Fire(ctx, event, hooks.Payload{
ToolName: tCtx.Name,
ToolInput: json.RawMessage(args),
ToolResponse: result,
})
if dec.ModifiedResult != nil {
result = *dec.ModifiedResult
}
if dec.AdditionalContext != "" {
result = appendHookContext(result, dec.AdditionalContext)
}
return result, err
}
}

// hookDenyMessage is returned to the model when a PreToolUse hook blocks a tool.
// It mirrors the approval-rejection wording so the model does not try to work
// around the policy.
func hookDenyMessage(reason string) string {
msg := "Tool execution was blocked by a hook policy."
if reason != "" {
msg += " Reason: " + reason
}
msg += " IMPORTANT: Do NOT retry this or attempt a workaround with a different tool or command. " +
"Respect the policy and either choose a different approach or ask the user how to proceed."
return msg
}

func appendHookContext(result, ctxText string) string {
if result == "" {
return ctxText
}
return result + "\n\n" + ctxText
}
Loading
Loading