mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 10:15:48 +00:00
feat(hooks): add script context injection
This commit is contained in:
1 parent
e4e4bb33fb
commit
03a196e613
9 files changed
+113
-20
No files matched your search
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -198,6 +199,7 @@ func (d *stdDispatcher) runSync(ctx context.Context, ev Event, chain []HookConfi
|
||||
evMut.ToolInput = cloneMap(ev.ToolInput)
|
||||
}
|
||||
mutated := false
|
||||
additionalContexts := make([]string, 0, len(chain))
|
||||
|
||||
for _, cfg := range chain {
|
||||
if !cfg.Enabled {
|
||||
@@ -259,6 +261,9 @@ func (d *stdDispatcher) runSync(ctx context.Context, ev Event, chain []HookConfi
|
||||
)
|
||||
}
|
||||
}
|
||||
if dec == DecisionAllow && scriptRes.AdditionalContext != "" {
|
||||
additionalContexts = append(additionalContexts, scriptRes.AdditionalContext)
|
||||
}
|
||||
|
||||
switch dec {
|
||||
case DecisionBlock:
|
||||
@@ -282,6 +287,7 @@ func (d *stdDispatcher) runSync(ctx context.Context, ev Event, chain []HookConfi
|
||||
}
|
||||
|
||||
result := FireResult{Decision: DecisionAllow}
|
||||
result.AdditionalContext = strings.Join(additionalContexts, "\n\n")
|
||||
if mutated {
|
||||
if evMut.ToolInput != nil {
|
||||
result.UpdatedToolInput = evMut.ToolInput
|
||||
|
||||
@@ -411,16 +411,45 @@ func TestNoopDispatcher_AlwaysAllows(t *testing.T) {
|
||||
// the dispatcher's source-tier gate can be exercised without invoking the
|
||||
// real goja runtime.
|
||||
type mutatingHandler struct {
|
||||
updated map[string]any
|
||||
updated map[string]any
|
||||
additionalContext string
|
||||
}
|
||||
|
||||
func (h *mutatingHandler) Execute(ctx context.Context, _ hooks.HookConfig, _ hooks.Event) (hooks.Decision, error) {
|
||||
if r := hooks.ScriptResultFrom(ctx); r != nil {
|
||||
r.UpdatedInput = h.updated
|
||||
r.AdditionalContext = h.additionalContext
|
||||
}
|
||||
return hooks.DecisionAllow, nil
|
||||
}
|
||||
|
||||
func TestDispatcher_UIScriptAdditionalContextAllowedWithoutInputMutation(t *testing.T) {
|
||||
cfg := newBaseHook(hooks.HandlerScript, hooks.EventUserPromptSubmit)
|
||||
cfg.Source = "ui"
|
||||
fs := &fakeStore{hooks: []hooks.HookConfig{cfg}}
|
||||
h := &mutatingHandler{
|
||||
updated: map[string]any{"rawInput": "must-not-apply"},
|
||||
additionalContext: "Call the required workflow tool.",
|
||||
}
|
||||
d := hooks.NewStdDispatcher(hooks.StdDispatcherOpts{
|
||||
Store: fs, Audit: hooks.NewAuditWriter(fs, ""),
|
||||
Handlers: map[hooks.HandlerType]hooks.Handler{hooks.HandlerScript: h},
|
||||
})
|
||||
|
||||
r, err := d.Fire(context.Background(), hooks.Event{
|
||||
EventID: "e-context", HookEvent: hooks.EventUserPromptSubmit, RawInput: "original",
|
||||
})
|
||||
if err != nil || r.Decision != hooks.DecisionAllow {
|
||||
t.Fatalf("decision=%q err=%v", r.Decision, err)
|
||||
}
|
||||
if r.AdditionalContext != "Call the required workflow tool." {
|
||||
t.Fatalf("additional context=%q", r.AdditionalContext)
|
||||
}
|
||||
if r.UpdatedRawInput != nil {
|
||||
t.Fatalf("UI source mutated raw input: %q", *r.UpdatedRawInput)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatcher_ScriptMutation_BuiltinSourceApplies verifies the source-tier
|
||||
// capability: a script hook with source="builtin" has its UpdatedInput applied
|
||||
// to FireResult.Updated*; a script hook with source="ui" does NOT.
|
||||
|
||||
@@ -187,7 +187,7 @@ func (h *ScriptHandler) Execute(ctx context.Context, cfg hooks.HookConfig, ev ho
|
||||
return hooks.DecisionError, sanitizeError(execErr)
|
||||
}
|
||||
|
||||
dec, reason, updated, parseErr := parseReturn(rt, result)
|
||||
dec, reason, additionalContext, updated, parseErr := parseReturn(rt, result)
|
||||
if parseErr != nil {
|
||||
return hooks.DecisionError, parseErr
|
||||
}
|
||||
@@ -208,6 +208,7 @@ func (h *ScriptHandler) Execute(ctx context.Context, cfg hooks.HookConfig, ev ho
|
||||
}
|
||||
if r := hooks.ScriptResultFrom(ctx); r != nil {
|
||||
r.Reason = reason
|
||||
r.AdditionalContext = additionalContext
|
||||
r.UpdatedInput = updated
|
||||
r.Stdout = stdout.String()
|
||||
}
|
||||
|
||||
@@ -134,31 +134,32 @@ func captureStdout(rt *goja.Runtime, buf *strings.Builder) {
|
||||
_ = rt.Set("console", console)
|
||||
}
|
||||
|
||||
// parseReturn enforces the `{decision, reason?, updatedInput?}` contract.
|
||||
// parseReturn enforces the
|
||||
// `{decision, reason?, additionalContext?, updatedInput?}` contract.
|
||||
// Any deviation (non-object, missing field, unknown decision string) maps to
|
||||
// DecisionError with a descriptive error.
|
||||
func parseReturn(rt *goja.Runtime, v goja.Value) (hooks.Decision, string, map[string]any, error) {
|
||||
func parseReturn(rt *goja.Runtime, v goja.Value) (hooks.Decision, string, string, map[string]any, error) {
|
||||
if v == nil || goja.IsUndefined(v) || goja.IsNull(v) {
|
||||
return hooks.DecisionError, "", nil, errors.New("script return: must be object with decision")
|
||||
return hooks.DecisionError, "", "", nil, errors.New("script return: must be object with decision")
|
||||
}
|
||||
obj := v.ToObject(rt)
|
||||
if obj == nil {
|
||||
return hooks.DecisionError, "", nil, errors.New("script return: must be object")
|
||||
return hooks.DecisionError, "", "", nil, errors.New("script return: must be object")
|
||||
}
|
||||
decRaw := obj.Get("decision")
|
||||
if decRaw == nil || goja.IsUndefined(decRaw) {
|
||||
return hooks.DecisionError, "", nil, errors.New("script return: missing decision field")
|
||||
return hooks.DecisionError, "", "", nil, errors.New("script return: missing decision field")
|
||||
}
|
||||
decStr, ok := decRaw.Export().(string)
|
||||
if !ok {
|
||||
return hooks.DecisionError, "", nil, errors.New("script return: decision must be string")
|
||||
return hooks.DecisionError, "", "", nil, errors.New("script return: decision must be string")
|
||||
}
|
||||
dec := hooks.Decision(decStr)
|
||||
switch dec {
|
||||
case hooks.DecisionAllow, hooks.DecisionBlock, hooks.DecisionAsk, hooks.DecisionDefer:
|
||||
// ok
|
||||
default:
|
||||
return hooks.DecisionError, "", nil, fmt.Errorf("script return: invalid decision %q", decStr)
|
||||
return hooks.DecisionError, "", "", nil, fmt.Errorf("script return: invalid decision %q", decStr)
|
||||
}
|
||||
var reason string
|
||||
if r := obj.Get("reason"); r != nil && !goja.IsUndefined(r) {
|
||||
@@ -166,11 +167,17 @@ func parseReturn(rt *goja.Runtime, v goja.Value) (hooks.Decision, string, map[st
|
||||
reason = s
|
||||
}
|
||||
}
|
||||
var additionalContext string
|
||||
if a := obj.Get("additionalContext"); a != nil && !goja.IsUndefined(a) {
|
||||
if s, ok := a.Export().(string); ok {
|
||||
additionalContext = s
|
||||
}
|
||||
}
|
||||
var updated map[string]any
|
||||
if u := obj.Get("updatedInput"); u != nil && !goja.IsUndefined(u) {
|
||||
if m, ok := u.Export().(map[string]any); ok {
|
||||
updated = m
|
||||
}
|
||||
}
|
||||
return dec, reason, updated, nil
|
||||
return dec, reason, additionalContext, updated, nil
|
||||
}
|
||||
@@ -73,6 +73,18 @@ func TestHappyPathAllow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowReturnsAdditionalContext(t *testing.T) {
|
||||
src := `function handle(event) { return {decision: "allow", additionalContext: "Use the required workflow."}; }`
|
||||
h := newTestHandler()
|
||||
dec, err, res := runWithResult(t, h, mkCfg(src), mkEvent(), 500*time.Millisecond)
|
||||
if err != nil || dec != hooks.DecisionAllow {
|
||||
t.Fatalf("decision=%v err=%v", dec, err)
|
||||
}
|
||||
if res.AdditionalContext != "Use the required workflow." {
|
||||
t.Fatalf("additional context=%q", res.AdditionalContext)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHappyPathBlock verifies block + reason round-trips.
|
||||
func TestHappyPathBlock(t *testing.T) {
|
||||
src := `function handle(event) { return {decision: "block", reason: "nope"}; }`
|
||||
|
||||
@@ -3,7 +3,7 @@ package hooks
|
||||
import "context"
|
||||
|
||||
// ScriptResult carries non-decision outputs from a script handler execution
|
||||
// (reason text, updatedInput proposal, captured stdout). The dispatcher
|
||||
// (reason text, additional context, updatedInput proposal, captured stdout). The dispatcher
|
||||
// provisions one via context before calling Handler.Execute; the handler
|
||||
// populates it on success. Phase 03 wires this into the dispatcher so that
|
||||
// only builtin-source hooks can apply UpdatedInput to the pipeline state.
|
||||
@@ -13,6 +13,10 @@ import "context"
|
||||
type ScriptResult struct {
|
||||
// Reason is the human-readable explanation returned by the script.
|
||||
Reason string
|
||||
// AdditionalContext is instruction text appended to the current run's
|
||||
// extra system prompt after the hook returns allow. It does not mutate the
|
||||
// original user input.
|
||||
AdditionalContext string
|
||||
// UpdatedInput is a proposed replacement for Event.ToolInput. Dispatcher
|
||||
// applies only when cfg.Source == "builtin" (source-tier capability).
|
||||
UpdatedInput map[string]any
|
||||
|
||||
@@ -151,12 +151,12 @@ func (d Decision) IsBlock() bool {
|
||||
// DecisionReason carries a human-readable explanation when Decision is
|
||||
// DecisionBlock (or DecisionAsk/DecisionDefer treated as block). This is
|
||||
// injected as a user message to trigger a retry iteration.
|
||||
//
|
||||
type FireResult struct {
|
||||
Decision Decision
|
||||
DecisionReason string
|
||||
UpdatedToolInput map[string]any
|
||||
UpdatedRawInput *string
|
||||
Decision Decision
|
||||
DecisionReason string
|
||||
AdditionalContext string
|
||||
UpdatedToolInput map[string]any
|
||||
UpdatedRawInput *string
|
||||
}
|
||||
|
||||
// ─── Config & execution structs ──────────────────────────────────────────────
|
||||
@@ -233,7 +233,7 @@ type Event struct {
|
||||
HookEvent HookEvent
|
||||
|
||||
// PostModelResponse fields (populated when HookEvent == EventPostModelResponse).
|
||||
ModelResponse string // the generated response content
|
||||
Thinking string // reasoning content (if any)
|
||||
ModelResponse string // the generated response content
|
||||
Thinking string // reasoning content (if any)
|
||||
ToolCalls []providers.ToolCall // empty for final response, populated if tool calls present
|
||||
}
|
||||
@@ -60,8 +60,16 @@ func (s *ContextStage) Execute(ctx context.Context, state *RunState) error {
|
||||
HookEvent: hooks.EventUserPromptSubmit,
|
||||
}); r.Decision == hooks.DecisionBlock {
|
||||
return fmt.Errorf("hook blocked user_prompt_submit")
|
||||
} else if r.UpdatedRawInput != nil {
|
||||
state.Input.Message = *r.UpdatedRawInput
|
||||
} else {
|
||||
if r.UpdatedRawInput != nil {
|
||||
state.Input.Message = *r.UpdatedRawInput
|
||||
}
|
||||
if r.AdditionalContext != "" {
|
||||
if state.Input.ExtraSystemPrompt != "" {
|
||||
state.Input.ExtraSystemPrompt += "\n\n"
|
||||
}
|
||||
state.Input.ExtraSystemPrompt += r.AdditionalContext
|
||||
}
|
||||
}
|
||||
|
||||
// 0. Inject context values (agent/tenant/user/workspace scoping, input guard, truncation).
|
||||
|
||||
@@ -2631,6 +2631,32 @@ func TestFinalizeStage_PopulatesFileSizes_SkipsNonexistent(t *testing.T) {
|
||||
|
||||
// --- ContextStage tests ---
|
||||
|
||||
func TestContextStage_AppendsHookAdditionalContextToSystemPromptInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
var capturedExtra string
|
||||
deps := &PipelineDeps{
|
||||
Hooks: toolStageHookDispatcher{fire: func(_ context.Context, ev hooks.Event) (hooks.FireResult, error) {
|
||||
if ev.HookEvent == hooks.EventUserPromptSubmit {
|
||||
return hooks.FireResult{Decision: hooks.DecisionAllow, AdditionalContext: "mandatory workflow"}, nil
|
||||
}
|
||||
return hooks.FireResult{Decision: hooks.DecisionAllow}, nil
|
||||
}},
|
||||
BuildMessages: func(_ context.Context, input *RunInput, _ []providers.Message, _ string) ([]providers.Message, error) {
|
||||
capturedExtra = input.ExtraSystemPrompt
|
||||
return []providers.Message{{Role: "system", Content: input.ExtraSystemPrompt}}, nil
|
||||
},
|
||||
}
|
||||
state := defaultState()
|
||||
state.Input.ExtraSystemPrompt = "existing context"
|
||||
stage := NewContextStage(deps)
|
||||
if err := stage.Execute(context.Background(), state); err != nil {
|
||||
t.Fatalf("Execute() error: %v", err)
|
||||
}
|
||||
if capturedExtra != "existing context\n\nmandatory workflow" {
|
||||
t.Fatalf("extra system prompt=%q", capturedExtra)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextStage_ResolveWorkspace(t *testing.T) {
|
||||
t.Parallel()
|
||||
wantWS := &workspace.WorkspaceContext{ActivePath: "/resolved"}
|
||||
|
||||
Reference in new issue
Block a user