diff --git a/pkg/script/builtins.go b/pkg/script/builtins.go index d8a643b..4933210 100644 --- a/pkg/script/builtins.go +++ b/pkg/script/builtins.go @@ -30,6 +30,9 @@ func EchoSpec() registry.BuiltinSpec { {Name: "text", Type: registry.StringT, Optional: true}, }, }, + // echo is the one builtin available on BOTH backends: the + // in-process interpreter and Sibyl's registered Echo activity. + Backends: []registry.Backend{registry.BackendMemory, registry.BackendTemporal}, } } diff --git a/pkg/script/catalog.go b/pkg/script/catalog.go new file mode 100644 index 0000000..0d0d2fa --- /dev/null +++ b/pkg/script/catalog.go @@ -0,0 +1,177 @@ +// Package script — catalog.go defines the COMPLETE builtin catalog: every +// verb the AgentScript grammar has ever supported, so the vocabulary is +// complete and backwards-compatible. Resolve recognizes all of them. +// +// Availability is per-backend, not per-existence: +// - Every verb here is resolvable (no more "unknown builtin" for a real +// verb just because we're on the temporal path). +// - Each verb declares which backends it runs on. The full historical +// set runs on BackendMemory (the in-process interpreter implements +// them). Only verbs with a registered Sibyl activity also list +// BackendTemporal — today, just echo. +// - A known verb targeted at a backend it doesn't support yields a +// distinct NotImplementedOnBackendError (see resolve), NOT an +// UnknownBuiltinError. Genuinely unknown names (typos, hallucinations) +// still fail as unknown — the safety net is preserved. +// +// Arg schemas here are intentionally permissive (variadic strings): the +// registry's job for memory verbs is vocabulary recognition, while the +// in-process interpreter does its own argument handling. echo keeps a +// precise schema because it is the temporal-path builtin. +// +// Porting a verb to temporal later = add BackendTemporal to its spec and +// register the matching Sibyl activity. Until then it is a known, +// memory-only verb that reports "not implemented on temporal". +package script + +import "github.com/vinodhalaharvi/agentscript/pkg/script/registry" + +// memoryVerbs is the complete historical verb set, all memory-backed. +// Sourced from the grammar's Action enum (the authoritative list). +var memoryVerbs = []string{ + "agent", + "analyze", + "ask", + "audio_video_merge", + "calendar", + "claude", + "codereview", + "codereview_focus", + "confirm", + "contact_find", + "crypto", + "deploy", + "dns_lookup", + "doc_create", + "drive_save", + "email", + "emoji_style", + "exec", + "fmap", + "foreach", + "form_create", + "form_responses", + "gcp_check", + "github_pages_html", + "hf_classify", + "hf_embeddings", + "hf_fill_mask", + "hf_generate", + "hf_image_classify", + "hf_image_generate", + "hf_ner", + "hf_qa", + "hf_similarity", + "hf_speech_to_text", + "hf_summarize", + "hf_translate", + "hf_zero_shot", + "http_check", + "if", + "image_analyze", + "image_audio_merge", + "image_generate", + "images_to_video", + "job_search", + "kg_connect", + "kg_cypher", + "kg_extract", + "kg_hybrid", + "kg_ingest", + "kg_path", + "kg_query", + "kg_status", + "list", + "maps_trip", + "match", + "mcp", + "mcp_agent", + "mcp_connect", + "mcp_list", + "mcp_search", + "mcp_search_install", + "meet", + "merge", + "news", + "news_headlines", + "notify", + "ollama", + "pdf_fields", + "pdf_fill", + "perplexity", + "perplexity_domain", + "perplexity_pro", + "perplexity_recent", + "pfmap", + "ping", + "places_search", + "plug_agent", + "port_check", + "rag_connect", + "rag_index", + "rag_query", + "rag_schema", + "rag_status", + "read", + "reddit", + "render", + "rss", + "save", + "schedule", + "search", + "sheet_append", + "sheet_create", + "ssl_check", + "stdin", + "stock", + "summarize", + "table_render", + "task", + "text_to_speech", + "translate", + "twitter", + "undeploy", + "video_analyze", + "video_generate", + "video_script", + "weather", + "whatsapp", + "whois", + "youtube_search", + "youtube_shorts", + "youtube_upload", +} + +// memorySpec builds a permissive, memory-only spec for a historical verb: +// any number of string args, recognized by Resolve, run by the in-process +// interpreter. +func memorySpec(name string) registry.BuiltinSpec { + return registry.BuiltinSpec{ + Name: name, + AgentID: "agentscript/" + name, + ArgSchema: registry.ArgSchema{ + Variadic: true, + VariadicType: registry.StringT, + }, + Backends: []registry.Backend{registry.BackendMemory}, + } +} + +// CompleteRegistry returns a registry containing the entire historical +// verb vocabulary (all memory-backed) plus echo (memory + temporal). This +// is the backwards-compatible registry: every real verb resolves; the +// backend decides availability. Front ends that want the full grammar +// recognized use this; DefaultRegistry (echo only) remains for the +// minimal temporal-only case. +func CompleteRegistry() *registry.Registry { + r := registry.New() + // echo: the one verb available on BOTH backends. + echo := EchoSpec() + echo.Backends = []registry.Backend{registry.BackendMemory, registry.BackendTemporal} + r.MustRegister(echo) + // every historical verb, memory-backed. + for _, v := range memoryVerbs { + r.MustRegister(memorySpec(v)) + } + return r +} diff --git a/pkg/script/catalog_test.go b/pkg/script/catalog_test.go new file mode 100644 index 0000000..e7c10f6 --- /dev/null +++ b/pkg/script/catalog_test.go @@ -0,0 +1,121 @@ +package script_test + +import ( + "context" + "errors" + "testing" + + "github.com/vinodhalaharvi/agentscript/pkg/script" + "github.com/vinodhalaharvi/agentscript/pkg/script/registry" +) + +// The contract this PR establishes: +// - The registry is COMPLETE: every historical verb resolves (no +// "unknown builtin" for a real verb). +// - Availability is per-backend: historical verbs run on memory; a +// known verb under temporal that isn't ported yields a distinct +// NotImplementedOnBackendError — NEVER UnknownBuiltinError. +// - Genuinely unknown names still fail as UnknownBuiltinError (safety +// net preserved). + +func TestCompleteRegistry_RecognizesAllHistoricalVerbs(t *testing.T) { + r := script.CompleteRegistry() + // A representative spread across the verb families. + for _, v := range []string{ + "echo", "search", "summarize", "hf_summarize", "hf_translate", + "mcp", "mcp_agent", "mcp_connect", "perplexity", "perplexity_pro", + "weather", "stock", "crypto", "email", "calendar", "translate", + "image_generate", "video_analyze", "rag_query", "kg_extract", + "if", "foreach", "match", "fmap", "pfmap", "exec", + } { + if _, ok := r.Lookup(v); !ok { + t.Errorf("CompleteRegistry should recognize historical verb %q", v) + } + } +} + +func TestCompleteRegistry_MemoryVerbResolvesOnMemory(t *testing.T) { + r := script.CompleteRegistry() + // hf_summarize is memory-only; under a memory block it RESOLVES clean + // (vocabulary + availability both pass). Note: memory *execution* is a + // later step — here we assert resolution succeeds, which is this PR's + // contract. Compile would invoke the temporal-only Finalize, so we + // stop at Resolve. + a, err := script.Parse(context.Background(), script.Source(`memory static ( hf_summarize "x" )`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if _, err := script.Resolve(context.Background(), r, a); err != nil { + t.Fatalf("memory-backed verb should resolve under memory: %v", err) + } +} + +func TestCompleteRegistry_MemoryVerbUnderTemporal_NotImplemented(t *testing.T) { + r := script.CompleteRegistry() + // hf_summarize under temporal: KNOWN verb, not available on temporal. + // This is caught at RESOLVE (availability check), before Finalize. + a, err := script.Parse(context.Background(), script.Source(`temporal static ( hf_summarize "x" )`)) + if err != nil { + t.Fatalf("parse: %v", err) + } + _, err = script.Resolve(context.Background(), r, a) + if err == nil { + t.Fatal("memory-only verb under temporal should fail at resolve") + } + var notImpl *script.NotImplementedOnBackendError + if !errors.As(err, ¬Impl) { + t.Fatalf("expected NotImplementedOnBackendError, got %T: %v", err, err) + } + var unknown *script.UnknownBuiltinError + if errors.As(err, &unknown) { + t.Error("a known verb must NOT produce UnknownBuiltinError") + } + if notImpl.Builtin != "hf_summarize" || notImpl.Backend != "temporal" { + t.Errorf("error fields = %q/%q, want hf_summarize/temporal", notImpl.Builtin, notImpl.Backend) + } +} + +func TestCompleteRegistry_EchoRunsOnTemporal(t *testing.T) { + r := script.CompleteRegistry() + // echo is the one verb on BOTH backends. It compiles end-to-end on + // temporal (Finalize produces a Plan)... + if _, err := script.Compile(context.Background(), r, script.Source(`temporal static ( echo "hi" )`)); err != nil { + t.Errorf("echo should compile on temporal: %v", err) + } + // ...and resolves on memory (memory execution is a later step). + a, _ := script.Parse(context.Background(), script.Source(`memory static ( echo "hi" )`)) + if _, err := script.Resolve(context.Background(), r, a); err != nil { + t.Errorf("echo should resolve on memory: %v", err) + } +} + +func TestCompleteRegistry_SafetyNetUnknownStillUnknown(t *testing.T) { + r := script.CompleteRegistry() + // A genuinely unknown name (hallucination) must STILL be unknown, + // on either backend — the safety net is preserved. + for _, src := range []string{ + `temporal static ( teleport "mars" )`, + `memory static ( teleport "mars" )`, + } { + _, err := script.Compile(context.Background(), r, script.Source(src)) + if err == nil { + t.Fatalf("unknown verb should fail: %s", src) + } + var unknown *script.UnknownBuiltinError + if !errors.As(err, &unknown) { + t.Errorf("%s: expected UnknownBuiltinError, got %T", src, err) + } + } +} + +func TestSupportsBackend_EmptyDefaultsToMemoryOnly(t *testing.T) { + // A spec with no declared backends must be memory-only — the safe + // default that prevents dispatching un-ported verbs to Temporal. + s := registry.BuiltinSpec{Name: "x"} + if !s.SupportsBackend(registry.BackendMemory) { + t.Error("empty Backends should support memory") + } + if s.SupportsBackend(registry.BackendTemporal) { + t.Error("empty Backends must NOT support temporal") + } +} diff --git a/pkg/script/lower_test.go b/pkg/script/lower_test.go index dd9b9bd..8c9c370 100644 --- a/pkg/script/lower_test.go +++ b/pkg/script/lower_test.go @@ -159,6 +159,10 @@ func TestCompile_CustomBuiltin(t *testing.T) { ArgSchema: registry.ArgSchema{ Params: []registry.ParamSpec{{Name: "text", Type: registry.StringT}}, }, + // This custom builtin is exercised under a temporal block, so it + // must declare temporal support (mirroring how a real ported verb + // is registered). + Backends: []registry.Backend{registry.BackendTemporal}, }) plan, err := script.Compile(context.Background(), r, script.Source(`temporal static ( shout "hey" )`)) if err != nil { diff --git a/pkg/script/registry/registry.go b/pkg/script/registry/registry.go index 288358b..b3e8500 100644 --- a/pkg/script/registry/registry.go +++ b/pkg/script/registry/registry.go @@ -126,6 +126,45 @@ type BuiltinSpec struct { // AuthHint declares the builtin's default credential source. Advisory // in the MVP. AuthHint AuthSource + // Backends declares which execution backends this builtin can run on. + // A builtin is always resolvable (so the vocabulary is complete and + // backwards-compatible), but availability is per-backend: a verb in + // the registry that lists only BackendMemory resolves fine, runs on + // the memory interpreter, and yields a clear "not implemented on + // temporal" — distinct from an unknown-verb error. Empty is treated + // as "memory only" for safety (a verb with no declared temporal + // activity must not be dispatched to Temporal). + Backends []Backend +} + +// Backend identifies an execution backend a builtin supports. It mirrors +// the ast.Backend keyword (memory | temporal) but lives here so the +// registry — the single source of truth for "what runs where" — has no +// dependency on the ast package. +type Backend int + +const ( + // BackendMemory is the in-process tree-walking interpreter, which + // implements the full historical verb set. + BackendMemory Backend = iota + // BackendTemporal is the durable Sibyl PlanWorkflow path; a verb is + // available here only once it has a registered Sibyl activity. + BackendTemporal +) + +// SupportsBackend reports whether the builtin can run on b. An empty +// Backends list means memory-only (the safe default): a verb with no +// explicitly declared backend must never be dispatched to Temporal. +func (s BuiltinSpec) SupportsBackend(b Backend) bool { + if len(s.Backends) == 0 { + return b == BackendMemory + } + for _, x := range s.Backends { + if x == b { + return true + } + } + return false } // === Registry ============================================================== diff --git a/pkg/script/resolve.go b/pkg/script/resolve.go index fc9b295..7f9b6dd 100644 --- a/pkg/script/resolve.go +++ b/pkg/script/resolve.go @@ -53,19 +53,19 @@ func ResolveWith(reg *registry.Registry) func(context.Context, ast.AST) (resolve } func resolveBlock(reg *registry.Registry, b ast.Block) (resolved.Block, error) { - body, err := resolveNode(reg, b.Body) + body, err := resolveNode(reg, b.Backend, b.Body) if err != nil { return resolved.Block{}, err } return resolved.Block{Backend: b.Backend, Mode: b.Mode, Body: body}, nil } -func resolveNode(reg *registry.Registry, n ast.Node) (resolved.Node, error) { +func resolveNode(reg *registry.Registry, backend ast.Backend, n ast.Node) (resolved.Node, error) { switch node := n.(type) { case ast.Pipeline: stages := make([]resolved.Node, 0, len(node.Stages)) for i, s := range node.Stages { - rs, err := resolveNode(reg, s) + rs, err := resolveNode(reg, backend, s) if err != nil { return nil, fmt.Errorf("stage %d: %w", i, err) } @@ -76,7 +76,7 @@ func resolveNode(reg *registry.Registry, n ast.Node) (resolved.Node, error) { case ast.Parallel: branches := make([]resolved.Node, 0, len(node.Branches)) for i, br := range node.Branches { - rb, err := resolveNode(reg, br) + rb, err := resolveNode(reg, backend, br) if err != nil { return nil, fmt.Errorf("branch %d: %w", i, err) } @@ -85,14 +85,16 @@ func resolveNode(reg *registry.Registry, n ast.Node) (resolved.Node, error) { return resolved.Parallel{Branches: branches}, nil case ast.Call: - return resolveCall(reg, node) + return resolveCall(reg, backend, node) default: return nil, fmt.Errorf("unknown AST node type %T", n) } } -func resolveCall(reg *registry.Registry, c ast.Call) (resolved.Node, error) { +func resolveCall(reg *registry.Registry, backend ast.Backend, c ast.Call) (resolved.Node, error) { + // Vocabulary check: is this a real verb at all? A genuinely unknown + // name (typo, hallucination) fails here — the safety net. spec, ok := reg.Lookup(c.Name) if !ok { return nil, &UnknownBuiltinError{Name: c.Name, Known: reg.Names()} @@ -100,9 +102,38 @@ func resolveCall(reg *registry.Registry, c ast.Call) (resolved.Node, error) { if err := validateArgs(c, spec); err != nil { return nil, err } + // Availability check: the verb exists, but does it run on THIS + // backend yet? A known verb targeted at an unsupported backend yields + // a distinct NotImplementedOnBackendError — never confused with an + // unknown verb. This is what keeps the registry complete (every real + // verb resolves) while being honest about where each can execute. + if err := checkBackend(c.Name, spec, backend); err != nil { + return nil, err + } return resolved.Call{Name: c.Name, Args: c.Args, Spec: spec}, nil } +// checkBackend verifies the builtin supports the block's backend. The +// ast.Backend keyword is mapped to the registry's Backend enum. +func checkBackend(name string, spec registry.BuiltinSpec, backend ast.Backend) error { + var rb registry.Backend + switch backend { + case ast.BackendMemory: + rb = registry.BackendMemory + case ast.BackendTemporal: + rb = registry.BackendTemporal + default: + // Unknown/zero backend: don't block resolution on it here; the + // parser guarantees a valid backend, and Finalize/dispatch will + // handle anything unexpected. + return nil + } + if !spec.SupportsBackend(rb) { + return &NotImplementedOnBackendError{Builtin: name, Backend: backend.String()} + } + return nil +} + // validateArgs checks a call's arguments against the builtin's schema: // arity (min required, max unless variadic) and per-argument type. func validateArgs(c ast.Call, spec registry.BuiltinSpec) error { @@ -181,6 +212,21 @@ func (e *UnknownBuiltinError) Error() string { return fmt.Sprintf("unknown builtin %q (known: %s)", e.Name, strings.Join(e.Known, ", ")) } +// NotImplementedOnBackendError is returned when a Call references a verb +// that IS in the registry (a real, known verb) but does not yet run on +// the block's target backend. It is deliberately distinct from +// UnknownBuiltinError: the verb is recognized vocabulary, it just isn't +// available on this backend yet (e.g. a memory-only verb under a temporal +// block, before it has a registered Sibyl activity). +type NotImplementedOnBackendError struct { + Builtin string + Backend string +} + +func (e *NotImplementedOnBackendError) Error() string { + return fmt.Sprintf("builtin %q is not implemented on the %s backend yet", e.Builtin, e.Backend) +} + // ArityError is returned when a call has the wrong number of arguments. type ArityError struct { Builtin string diff --git a/pkg/script/resolve_test.go b/pkg/script/resolve_test.go index b87d695..2eee335 100644 --- a/pkg/script/resolve_test.go +++ b/pkg/script/resolve_test.go @@ -19,8 +19,9 @@ func echoReg(t *testing.T) *registry.Registry { t.Helper() r := registry.New() r.MustRegister(registry.BuiltinSpec{ - Name: "echo", - AgentID: "agentscript/echo", + Name: "echo", + Backends: []registry.Backend{registry.BackendTemporal, registry.BackendMemory}, + AgentID: "agentscript/echo", ArgSchema: registry.ArgSchema{ Params: []registry.ParamSpec{{Name: "message", Type: registry.StringT}}, }, @@ -213,8 +214,9 @@ func TestResolve_TooManyArgs(t *testing.T) { func TestResolve_OptionalArg(t *testing.T) { r := registry.New() r.MustRegister(registry.BuiltinSpec{ - Name: "greet", - AgentID: "agentscript/greet", + Name: "greet", + Backends: []registry.Backend{registry.BackendTemporal, registry.BackendMemory}, + AgentID: "agentscript/greet", ArgSchema: registry.ArgSchema{ Params: []registry.ParamSpec{ {Name: "name", Type: registry.StringT}, @@ -243,8 +245,9 @@ func TestResolve_OptionalArg(t *testing.T) { func TestResolve_Variadic(t *testing.T) { r := registry.New() r.MustRegister(registry.BuiltinSpec{ - Name: "concat", - AgentID: "agentscript/concat", + Name: "concat", + Backends: []registry.Backend{registry.BackendTemporal, registry.BackendMemory}, + AgentID: "agentscript/concat", ArgSchema: registry.ArgSchema{ Params: []registry.ParamSpec{{Name: "first", Type: registry.StringT}}, Variadic: true, @@ -271,6 +274,7 @@ func TestResolve_NoArgBuiltin(t *testing.T) { r := registry.New() r.MustRegister(registry.BuiltinSpec{ Name: "now", + Backends: []registry.Backend{registry.BackendTemporal, registry.BackendMemory}, AgentID: "agentscript/now", ArgSchema: registry.ArgSchema{}, // no params })