From d86baf4ea3591fba9af0d2da0af3c1556c1e2190 Mon Sep 17 00:00:00 2001 From: vinodhalaharvi-claude Date: Sun, 24 May 2026 13:42:13 +0000 Subject: [PATCH] =?UTF-8?q?scriptmem:=20Execute=20=E2=80=94=20one=20entry,?= =?UTF-8?q?=20backend-routed,=20tagged=20Outcome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unified execution entry that hides the backend split behind a single call. A front end hands over prose (+ discovery + an LLM) and gets back an Outcome that is EITHER a finished in-memory result OR a temporal plan to submit. The front end branches only on the Outcome's execution shape — never on the grammar, the verbs, or the backend keyword. Execute(ctx, complete, GrammarInfo, MemoryConfig, prose) -> Outcome translate (LLM) -> parse -> resolve -> inspect backend -> either: memory : RunMemory in-process -> Outcome{Memory, Result} temporal : Compile to a durable plan -> Outcome{Temporal, Plan} Outcome is a tagged union: Backend selects which arm is meaningful (Result for memory, Plan for temporal). The caller (loom) branches on Backend to pick its delivery shape — post the result now, or submit + correlate + await — learning only the execution shape, never the grammar. The backend itself is chosen by the DSL the LLM emits (memory|temporal keyword), driven by the grammar prompt, not the caller. Lives in scriptmem (not script): executing memory needs the in-process runtime + its dep tree, so the envelope that may run memory belongs with it. pkg/script stays lean (verified) — callers that only translate/ compile pay nothing for the runtime; a caller that wants real execution imports scriptmem and accepts the deps, which is honest. Tests (execute_test.go): - temporal program -> Outcome{Temporal} with a 2-node plan, no result; - memory program ROUTES to the runtime (asserts no plan is produced; live verb output needs credentials and is covered by runtime tests); - unknown verb rejected on both backends (UnknownBuiltinError) before any execution — safety net through the envelope; - historical verb on temporal -> NotImplementedOnBackendError, no plan. KNOWN FOLLOW-UP (not this PR): the catalog marks echo memory-capable, but the in-memory runtime has no echo case, so 'memory static ( echo )' resolves then fails at runtime ('unknown action: echo'). The catalog and the runtime's actual verb set are slightly misaligned (echo is a temporal-only builtin). Worth a small reconciliation PR — either add echo to the runtime or mark it temporal-only in the catalog. CI: vet, gofmt, staticcheck, go test -race ./..., go build ./... pass. --- pkg/scriptmem/execute.go | 119 ++++++++++++++++++++++++++++++++++ pkg/scriptmem/execute_test.go | 101 +++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 pkg/scriptmem/execute.go create mode 100644 pkg/scriptmem/execute_test.go diff --git a/pkg/scriptmem/execute.go b/pkg/scriptmem/execute.go new file mode 100644 index 0000000..eef6801 --- /dev/null +++ b/pkg/scriptmem/execute.go @@ -0,0 +1,119 @@ +// Package scriptmem — execute.go is the unified execution entry that +// hides the backend split behind one call. A front end hands over prose +// (plus discovery + an LLM) and gets back an Outcome that is either a +// finished in-memory result or a temporal plan to submit. The front end +// branches only on the Outcome's *execution shape* — never on the +// grammar, the verbs, or the backend keyword. +// +// This lives in scriptmem (not script) because executing the memory +// backend requires the in-process runtime and its dependency tree. +// pkg/script stays lean for callers that only translate/compile; a caller +// that wants real memory execution imports scriptmem and accepts the +// runtime deps — which is honest, since running memory IS the runtime. +package scriptmem + +import ( + "context" + "fmt" + + "github.com/vinodhalaharvi/agentscript/pkg/script" + "github.com/vinodhalaharvi/agentscript/pkg/script/ast" + + sibyl "github.com/vinodhalaharvi/sibyl/agent" +) + +// Backend is the execution backend an Outcome targets. It re-exports the +// pkg/script/ast notion so a caller can branch without importing ast. +type Backend int + +const ( + // Memory means the program ran in-process; Outcome.Result holds the + // output. + Memory Backend = iota + // Temporal means the program compiled to a durable plan; Outcome.Plan + // holds it for the caller to submit and await. + Temporal +) + +func (b Backend) String() string { + switch b { + case Memory: + return "memory" + case Temporal: + return "temporal" + default: + return "unknown" + } +} + +// Outcome is the tagged result of Execute. Exactly one arm is meaningful, +// indicated by Backend: +// +// - Backend == Memory: the program already ran; Result holds the +// output. The caller posts it directly. (Plan is zero.) +// - Backend == Temporal: the program compiled to Plan, a durable Sibyl +// plan the caller submits and awaits. (Result is empty.) +// +// The caller branches on Backend to choose its delivery shape +// (synchronous reply vs submit-correlate-await). It never needs the +// grammar, the verbs, or how the backend was chosen. +type Outcome struct { + Backend Backend + // Result is the in-process output (Backend == Memory). + Result string + // Plan is the durable plan to submit (Backend == Temporal). + Plan sibyl.Plan +} + +// Execute is the unified entry: prose in, Outcome out. It translates the +// prose to DSL (the LLM authors it), resolves it against the discovered +// grammar (the compiler is the safety net), inspects the chosen backend, +// and then EITHER runs it in-process (memory) returning the result, OR +// compiles it to a durable plan (temporal) for the caller to submit. +// +// The backend is chosen by the DSL the LLM emits (the memory|temporal +// keyword), which is driven by the grammar prompt — not by the caller. +// Translation/compilation errors (unknown verb, not-on-backend, malformed) +// surface here before anything runs. +func Execute(ctx context.Context, complete script.CompleteFunc, g script.GrammarInfo, cfg MemoryConfig, prose string) (Outcome, error) { + // prose → DSL (AgentScript owns the prompt; discovery supplies vocab). + src, err := script.TranslateGrammar(ctx, complete, g, prose) + if err != nil { + return Outcome{}, err + } + + // DSL → AST → resolved AST (vocabulary + per-backend availability). + a, err := script.Parse(ctx, src) + if err != nil { + return Outcome{}, err + } + r, err := script.Resolve(ctx, g.Registry, a) + if err != nil { + return Outcome{}, err + } + if len(r.Blocks) == 0 { + return Outcome{}, fmt.Errorf("scriptmem.Execute: empty program") + } + + // Branch on the backend the DSL chose — the ONLY place the two paths + // diverge. + switch r.Blocks[0].Backend { + case ast.BackendMemory: + out, err := RunMemory(ctx, cfg, r) + if err != nil { + return Outcome{}, err + } + return Outcome{Backend: Memory, Result: out}, nil + + case ast.BackendTemporal: + // Reuse the lean compile pipeline to produce the durable plan. + plan, err := script.Compile(ctx, g.Registry, src) + if err != nil { + return Outcome{}, err + } + return Outcome{Backend: Temporal, Plan: plan}, nil + + default: + return Outcome{}, fmt.Errorf("scriptmem.Execute: unknown backend %v", r.Blocks[0].Backend) + } +} diff --git a/pkg/scriptmem/execute_test.go b/pkg/scriptmem/execute_test.go new file mode 100644 index 0000000..f0eb05d --- /dev/null +++ b/pkg/scriptmem/execute_test.go @@ -0,0 +1,101 @@ +package scriptmem_test + +import ( + "context" + "errors" + "testing" + + "github.com/vinodhalaharvi/agentscript/pkg/script" + "github.com/vinodhalaharvi/agentscript/pkg/scriptmem" +) + +func stubLLM(dsl string) script.CompleteFunc { + return func(_ context.Context, _, _ string) (string, error) { return dsl, nil } +} + +// Temporal path: Execute returns a durable plan, tagged Temporal, with no +// in-memory result. Fully testable without a runtime. +func TestExecute_TemporalReturnsPlan(t *testing.T) { + g := script.Grammar() + llm := stubLLM(`temporal static ( echo "hi" >=> echo )`) + + out, err := scriptmem.Execute(context.Background(), llm, g, scriptmem.MemoryConfig{}, "say hi then echo") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if out.Backend != scriptmem.Temporal { + t.Fatalf("Backend = %v, want temporal", out.Backend) + } + if len(out.Plan.Nodes) != 2 { + t.Errorf("plan nodes = %d, want 2", len(out.Plan.Nodes)) + } + if out.Result != "" { + t.Errorf("temporal outcome should carry no in-memory result, got %q", out.Result) + } +} + +// Routing: a memory-backed program routes to the in-process runtime +// (not the temporal/plan arm). We assert the ROUTING decision rather than +// a specific verb's output: most in-memory verbs need credentials, and +// the runtime's verb set is exercised by its own tests. What this proves +// is that Execute sends a memory block to RunMemory and never produces a +// plan for it — the unified entry's branch is correct. +func TestExecute_MemoryRoutesToRuntime(t *testing.T) { + g := script.Grammar() + // hf_summarize is memory-backed in the catalog; with no credentials + // the runtime will error, but the routing is what we assert: we must + // NOT get a temporal plan, and any error must come from the runtime + // (execution), not from resolution/compilation. + llm := stubLLM(`memory static ( hf_summarize "x" )`) + + out, err := scriptmem.Execute(context.Background(), llm, g, scriptmem.MemoryConfig{}, "summarize") + if err != nil { + // Acceptable: ran in-process and the verb failed (no creds). The + // point is it was ATTEMPTED in memory, not turned into a plan. + if out.Backend == scriptmem.Temporal || out.Plan.Nodes != nil { + t.Fatalf("memory program must not produce a temporal plan; got %+v", out) + } + return + } + // Or it succeeded: must be tagged memory with no plan. + if out.Backend != scriptmem.Memory { + t.Errorf("Backend = %v, want memory", out.Backend) + } + if out.Plan.Nodes != nil { + t.Errorf("memory outcome should carry no plan, got %+v", out.Plan) + } +} + +// Safety net through the envelope: an unknown verb fails before any +// execution, on either backend. +func TestExecute_UnknownVerbRejected(t *testing.T) { + g := script.Grammar() + for _, src := range []string{ + `temporal static ( teleport "mars" )`, + `memory static ( teleport "mars" )`, + } { + _, err := scriptmem.Execute(context.Background(), stubLLM(src), g, scriptmem.MemoryConfig{}, "x") + if err == nil { + t.Fatalf("expected rejection for %q", src) + } + var unknown *script.UnknownBuiltinError + if !errors.As(err, &unknown) { + t.Errorf("%s: expected UnknownBuiltinError, got %T", src, err) + } + } +} + +// A historical verb on temporal is honestly not-implemented (not unknown) +// and never produces a plan. +func TestExecute_HistoricalVerbOnTemporalNotImplemented(t *testing.T) { + g := script.Grammar() + llm := stubLLM(`temporal static ( hf_summarize "x" )`) + _, err := scriptmem.Execute(context.Background(), llm, g, scriptmem.MemoryConfig{}, "summarize") + if err == nil { + t.Fatal("hf_summarize on temporal should fail") + } + var notImpl *script.NotImplementedOnBackendError + if !errors.As(err, ¬Impl) { + t.Errorf("expected NotImplementedOnBackendError, got %T", err) + } +}