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
119 changes: 119 additions & 0 deletions pkg/scriptmem/execute.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
101 changes: 101 additions & 0 deletions pkg/scriptmem/execute_test.go
Original file line number Diff line number Diff line change
@@ -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, &notImpl) {
t.Errorf("expected NotImplementedOnBackendError, got %T", err)
}
}
Loading