mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-09-18 04:19:30 +00:00
Wave 1 Phase 03. Wires the Phase 02 Goja handler into the dispatcher, widens
the DB schema for script + builtin sources, and refactors Dispatcher.Fire to
return a FireResult so builtin-source hooks can mutate event input.
Dispatcher:
- FireResult{Decision, UpdatedToolInput, UpdatedRawInput} replaces the plain
Decision return. stdDispatcher.runSync keeps a local evMut copy so a
builtin-source hook's updatedInput flows to downstream hooks in the chain
and to the caller; non-builtin (source=ui) script mutations are dropped +
WARN-logged (defense-in-depth; source tier enforced at the dispatcher, not
only at the handler).
- applyBuiltinMutation + placeholder builtinAllowlistFor (rawInput, toolInput)
— Phase 04 overrides the allowlist from builtins.yaml.
- noopDispatcher returns FireResult{DecisionAllow}.
- Script mutation tests: TestDispatcher_ScriptMutation_BuiltinSourceApplies,
TestDispatcher_ScriptMutation_UISourceDenied.
Pipeline + callers (14 Fire sites refactored):
- FireHook wrapper returns FireResult; context_stage.go:52 applies
UpdatedRawInput → state.Input.Message; tool_stage.go:52 applies
UpdatedToolInput → tc.Arguments before ExecuteToolCall.
- delegate_bridge + delegate_tool keep the Decision branch; Updated* ignored.
- dispatcher_test / delegate_bridge_test / delegate_tool_hooks_test /
integration test helpers updated for the new shape.
Validation:
- edition_gate allows HandlerScript on every edition (sandboxed, no shell
escape surface).
- config.validateHandler rejects empty source, source > 32 KiB, goja compile
error; validateTimeout rejects on_timeout=ask|defer (reserved).
- Six new config tests cover the script path.
Migrations:
- PG migration 000053 relaxes handler_type + source CHECKs and drops
uq_hooks_{global,tenant,agent} — scripts routinely want many small hooks
per event. RequiredSchemaVersion → 53.
- SQLite SchemaVersion → 21; patch 20 is a SELECT 1 placeholder because
SQLite can't ALTER a CHECK — rebuildAgentHooksV21 runs outside the
migration tx (parallel to backfillV16) to rename/recreate the table with
widened CHECKs. schema.sql fresh-DB path matches.
- H9 fix: PGHookStore.Create + SqliteHookStore.Create honor caller-provided
cfg.ID (uuid.Nil falls back to UUIDv7), unblocking Phase 04 idempotent
UUIDv5 seed. TestCreateHonorsFixedID on both stores.
Config:
- config.HooksConfig{ScriptConcurrency, ScriptPerTenantConcurrency,
ScriptCacheSize, BuiltinDisable}; buildHookHandlers wires the script
handler with the caps from appCfg.Hooks.
Gates green: go build ./... + sqliteonly, go test -race -count=1 across
hooks/pipeline/sqlitestore.
175 lines
5.5 KiB
Go
175 lines
5.5 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/hooks"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
// fakeDispatcher records Fire calls and returns a preset decision.
|
|
type fakeDispatcher struct {
|
|
decision hooks.Decision
|
|
calls int
|
|
}
|
|
|
|
func (f *fakeDispatcher) Fire(_ context.Context, _ hooks.Event) (hooks.FireResult, error) {
|
|
f.calls++
|
|
return hooks.FireResult{Decision: f.decision}, nil
|
|
}
|
|
|
|
// --- minimal noop stores to satisfy AgentLinkStore / AgentCRUDStore ---
|
|
|
|
type noopAgentLink struct{}
|
|
|
|
func (noopAgentLink) CreateLink(_ context.Context, _ *store.AgentLinkData) error { return nil }
|
|
func (noopAgentLink) DeleteLink(_ context.Context, _ uuid.UUID) error { return nil }
|
|
func (noopAgentLink) UpdateLink(_ context.Context, _ uuid.UUID, _ map[string]any) error {
|
|
return nil
|
|
}
|
|
func (noopAgentLink) GetLink(_ context.Context, _ uuid.UUID) (*store.AgentLinkData, error) {
|
|
return nil, nil
|
|
}
|
|
func (noopAgentLink) ListLinksFrom(_ context.Context, _ uuid.UUID) ([]store.AgentLinkData, error) {
|
|
return nil, nil
|
|
}
|
|
func (noopAgentLink) ListLinksTo(_ context.Context, _ uuid.UUID) ([]store.AgentLinkData, error) {
|
|
return nil, nil
|
|
}
|
|
func (noopAgentLink) CanDelegate(_ context.Context, _, _ uuid.UUID) (bool, error) {
|
|
return true, nil
|
|
}
|
|
func (noopAgentLink) GetLinkBetween(_ context.Context, _, _ uuid.UUID) (*store.AgentLinkData, error) {
|
|
return nil, nil
|
|
}
|
|
func (noopAgentLink) DelegateTargets(_ context.Context, _ uuid.UUID) ([]store.AgentLinkData, error) {
|
|
return nil, nil
|
|
}
|
|
func (noopAgentLink) SearchDelegateTargets(_ context.Context, _ uuid.UUID, _ string, _ int) ([]store.AgentLinkData, error) {
|
|
return nil, nil
|
|
}
|
|
func (noopAgentLink) SearchDelegateTargetsByEmbedding(_ context.Context, _ uuid.UUID, _ []float32, _ int) ([]store.AgentLinkData, error) {
|
|
return nil, nil
|
|
}
|
|
func (noopAgentLink) DeleteTeamLinksForAgent(_ context.Context, _, _ uuid.UUID) error { return nil }
|
|
|
|
type noopAgentCRUD struct {
|
|
keyToID map[string]uuid.UUID
|
|
}
|
|
|
|
func (n noopAgentCRUD) Create(_ context.Context, _ *store.AgentData) error { return nil }
|
|
func (n noopAgentCRUD) GetByKey(_ context.Context, key string) (*store.AgentData, error) {
|
|
id, ok := n.keyToID[key]
|
|
if !ok {
|
|
id = uuid.New()
|
|
}
|
|
return &store.AgentData{BaseModel: store.BaseModel{ID: id}, AgentKey: key}, nil
|
|
}
|
|
func (n noopAgentCRUD) GetByID(_ context.Context, _ uuid.UUID) (*store.AgentData, error) {
|
|
return nil, nil
|
|
}
|
|
func (n noopAgentCRUD) GetByIDUnscoped(_ context.Context, _ uuid.UUID) (*store.AgentData, error) {
|
|
return nil, nil
|
|
}
|
|
func (n noopAgentCRUD) GetByKeys(_ context.Context, _ []string) ([]store.AgentData, error) {
|
|
return nil, nil
|
|
}
|
|
func (n noopAgentCRUD) GetByIDs(_ context.Context, _ []uuid.UUID) ([]store.AgentData, error) {
|
|
return nil, nil
|
|
}
|
|
func (n noopAgentCRUD) Update(_ context.Context, _ uuid.UUID, _ map[string]any) error { return nil }
|
|
func (n noopAgentCRUD) Delete(_ context.Context, _ uuid.UUID) error { return nil }
|
|
func (n noopAgentCRUD) List(_ context.Context, _ string) ([]store.AgentData, error) { return nil, nil }
|
|
func (n noopAgentCRUD) GetDefault(_ context.Context) (*store.AgentData, error) { return nil, nil }
|
|
|
|
// --- helpers ---
|
|
|
|
func makeDelegateCtx() context.Context {
|
|
ctx := store.WithAgentID(context.Background(), uuid.New())
|
|
ctx = store.WithTenantID(ctx, uuid.New())
|
|
ctx = store.WithAgentKey(ctx, "parent-agent")
|
|
return ctx
|
|
}
|
|
|
|
// --- tests ---
|
|
|
|
func TestDelegateTool_SubagentStartBlock_AbortsDispatch(t *testing.T) {
|
|
runCalled := 0
|
|
runFn := func(_ context.Context, _ DelegateRequest) (DelegateResult, error) {
|
|
runCalled++
|
|
return DelegateResult{Content: "ok"}, nil
|
|
}
|
|
|
|
tool := NewDelegateTool(noopAgentLink{}, noopAgentCRUD{}, nil, runFn)
|
|
disp := &fakeDispatcher{decision: hooks.DecisionBlock}
|
|
tool.SetHookDispatcher(disp)
|
|
|
|
result := tool.Execute(makeDelegateCtx(), map[string]any{
|
|
"agent_key": "child-agent",
|
|
"task": "do something",
|
|
"mode": "sync",
|
|
})
|
|
|
|
if result == nil || !result.IsError {
|
|
t.Fatal("expected error result when hook blocks")
|
|
}
|
|
if runCalled != 0 {
|
|
t.Errorf("runFn must not be called; got %d calls", runCalled)
|
|
}
|
|
if disp.calls != 1 {
|
|
t.Errorf("expected 1 dispatcher Fire call; got %d", disp.calls)
|
|
}
|
|
}
|
|
|
|
func TestDelegateTool_SubagentStartAllow_ProceedsToRun(t *testing.T) {
|
|
runCalled := 0
|
|
runFn := func(_ context.Context, _ DelegateRequest) (DelegateResult, error) {
|
|
runCalled++
|
|
return DelegateResult{Content: "done"}, nil
|
|
}
|
|
|
|
tool := NewDelegateTool(noopAgentLink{}, noopAgentCRUD{}, nil, runFn)
|
|
disp := &fakeDispatcher{decision: hooks.DecisionAllow}
|
|
tool.SetHookDispatcher(disp)
|
|
|
|
result := tool.Execute(makeDelegateCtx(), map[string]any{
|
|
"agent_key": "child-agent",
|
|
"task": "do something",
|
|
"mode": "sync",
|
|
})
|
|
|
|
if result != nil && result.IsError {
|
|
t.Fatalf("unexpected error: %s", result.ForLLM)
|
|
}
|
|
if runCalled != 1 {
|
|
t.Errorf("expected runFn called once; got %d", runCalled)
|
|
}
|
|
}
|
|
|
|
func TestDelegateTool_NilDispatcher_SkipsHook(t *testing.T) {
|
|
runCalled := 0
|
|
runFn := func(_ context.Context, _ DelegateRequest) (DelegateResult, error) {
|
|
runCalled++
|
|
return DelegateResult{Content: "done"}, nil
|
|
}
|
|
|
|
// No SetHookDispatcher — hookDispatcher stays nil.
|
|
tool := NewDelegateTool(noopAgentLink{}, noopAgentCRUD{}, nil, runFn)
|
|
|
|
result := tool.Execute(makeDelegateCtx(), map[string]any{
|
|
"agent_key": "child-agent",
|
|
"task": "do something",
|
|
"mode": "sync",
|
|
})
|
|
|
|
if result != nil && result.IsError {
|
|
t.Fatalf("unexpected error: %s", result.ForLLM)
|
|
}
|
|
if runCalled != 1 {
|
|
t.Errorf("expected runFn called once; got %d", runCalled)
|
|
}
|
|
}
|