From 26a064a757560142f7f2580026e0de035dec929f Mon Sep 17 00:00:00 2001 From: vinodhalaharvi-claude Date: Sun, 24 May 2026 11:35:59 +0000 Subject: [PATCH] =?UTF-8?q?script:=20add=20Grammar()=20discovery=20?= =?UTF-8?q?=E2=80=94=20front=20ends=20query=20the=20grammar,=20never=20emb?= =?UTF-8?q?ed=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A front end should not name a registry, hardcode a verb list, or change when the grammar grows. Grammar() is the single discovery entry point: a caller asks 'what can be done?', pipes the result into translation, and forwards the output — never inspecting the contents. When AgentScript adds verbs or restructures, the front end reflects it automatically, because it FETCHES the grammar instead of embedding knowledge of it. AgentScript is the source of truth; the front end is a dumb pipe. New (pkg/script/grammar.go): - GrammarInfo { Registry, Verbs, Operators, Backends } — self-describing discovery result. Registry is passed straight to Translate/Compile (callers are not expected to inspect it); Verbs/Operators/Backends are advisory metadata for help/docs/CLI surfaces. - Grammar() GrammarInfo — THE discovery entry, backed by CompleteRegistry (full vocabulary, per-backend availability decided at Resolve). Lists all 112 verbs, the >=> and <*> operators, and the memory/temporal backends. New (pkg/script/submit.go): - TranslateGrammar(ctx, complete, GrammarInfo, prose) — discovery-driven translate: takes GrammarInfo instead of a registry, so a front end pipes Grammar() straight in without ever touching or naming a registry. - CompileGrammar(ctx, GrammarInfo, src) — the matching compile entry. Tests (grammar_test.go): Grammar exposes the full sorted vocabulary + representative families; describes >=> / <*> and both backends; the discovery-driven Translate+Compile path works end to end without naming a registry; and a historical verb emitted under temporal surfaces NotImplementedOnBackendError (known, not unknown) — proving discovery composes with the per-backend availability contract. loom can now call Grammar() and pipe it through, staying fully decoupled from the grammar's contents and evolution. (loom change is a separate PR.) Purely additive; existing API unchanged. CI: vet, gofmt, staticcheck, go test -race ./..., go build ./... — pass. --- pkg/script/grammar.go | 71 +++++++++++++++++++++++++++++++++ pkg/script/grammar_test.go | 82 ++++++++++++++++++++++++++++++++++++++ pkg/script/submit.go | 16 ++++++++ 3 files changed, 169 insertions(+) create mode 100644 pkg/script/grammar.go create mode 100644 pkg/script/grammar_test.go diff --git a/pkg/script/grammar.go b/pkg/script/grammar.go new file mode 100644 index 0000000..7694d33 --- /dev/null +++ b/pkg/script/grammar.go @@ -0,0 +1,71 @@ +// Package script — grammar.go is the discovery surface: a single entry +// point a front end calls to learn the complete grammar without knowing +// anything about how it is built or what is in it. +// +// The point is decoupling. A front end (loom, a CLI, anything) should not +// name a particular registry, hardcode a verb list, or change when the +// grammar grows. It calls Grammar(), pipes the result into Translate, and +// forwards results — never inspecting the contents. When AgentScript adds +// verbs, restructures registries, or changes the grammar, the front end +// reflects it automatically, because it fetches the grammar rather than +// embedding knowledge of it. +// +// AgentScript is the single source of truth for the grammar; the front +// end is a dumb pipe that asks "what can be done?" and forwards the +// answer. +package script + +import "github.com/vinodhalaharvi/agentscript/pkg/script/registry" + +// GrammarInfo is the self-describing result of discovery. It carries the +// registry a caller passes straight to Translate/Compile (without +// inspecting it) plus advisory, human-readable metadata that tooling +// (docs, a CLI, a help command) can surface. A front end that only wants +// to translate prose uses Registry and ignores the rest. +type GrammarInfo struct { + // Registry is the complete builtin catalog. Pass it to Translate and + // Compile. Callers are not expected to inspect it. + Registry *registry.Registry + + // Verbs is the advisory list of available verb names (sorted), + // derived from the registry. For tooling/help surfaces; the + // authoritative validation always happens in Resolve against + // Registry. + Verbs []string + + // Operators describes the composition operators the grammar supports, + // for help/discovery surfaces. + Operators []OperatorInfo + + // Backends lists the execution backends the grammar can target. + Backends []string +} + +// OperatorInfo describes a composition operator for discovery surfaces. +type OperatorInfo struct { + Symbol string + Name string + Desc string +} + +// Grammar returns the complete grammar a front end can use: the full +// builtin catalog plus self-describing metadata. This is THE discovery +// entry point — a front end calls it, passes GrammarInfo.Registry into +// Translate, and never needs to know which registry backs it or when it +// changes. +// +// It is backed by CompleteRegistry (every historical verb, availability +// decided per-backend at Resolve), so discovery always reflects the full +// current vocabulary. +func Grammar() GrammarInfo { + reg := CompleteRegistry() + return GrammarInfo{ + Registry: reg, + Verbs: reg.Names(), + Operators: []OperatorInfo{ + {Symbol: ">=>", Name: "pipe", Desc: "sequential composition: left output feeds the right"}, + {Symbol: "<*>", Name: "fanout", Desc: "parallel fanout: branches run on the same input"}, + }, + Backends: []string{"memory", "temporal"}, + } +} diff --git a/pkg/script/grammar_test.go b/pkg/script/grammar_test.go new file mode 100644 index 0000000..99a6cc8 --- /dev/null +++ b/pkg/script/grammar_test.go @@ -0,0 +1,82 @@ +package script_test + +import ( + "context" + "errors" + "sort" + "testing" + + "github.com/vinodhalaharvi/agentscript/pkg/script" +) + +func TestGrammar_ReturnsCompleteVocabulary(t *testing.T) { + g := script.Grammar() + if g.Registry == nil { + t.Fatal("Grammar().Registry must not be nil") + } + if len(g.Verbs) < 100 { + t.Errorf("Grammar should expose the full vocabulary; got %d verbs", len(g.Verbs)) + } + if !sort.StringsAreSorted(g.Verbs) { + t.Error("Grammar().Verbs should be sorted") + } + want := map[string]bool{"echo": false, "hf_summarize": false, "mcp_agent": false, "perplexity": false} + for _, v := range g.Verbs { + if _, ok := want[v]; ok { + want[v] = true + } + } + for v, found := range want { + if !found { + t.Errorf("Grammar should advertise %q", v) + } + } +} + +func TestGrammar_DescribesOperatorsAndBackends(t *testing.T) { + g := script.Grammar() + syms := map[string]bool{} + for _, op := range g.Operators { + syms[op.Symbol] = true + } + if !syms[">=>"] || !syms["<*>"] { + t.Errorf("operators should include >=> and <*>, got %v", syms) + } + if len(g.Backends) != 2 { + t.Errorf("expected memory+temporal backends, got %v", g.Backends) + } +} + +func TestGrammar_DiscoveryDrivenTranslateAndCompile(t *testing.T) { + g := script.Grammar() + llm := func(_ context.Context, _, _ string) (string, error) { + return `temporal static ( echo "hi" )`, nil + } + src, err := script.TranslateGrammar(context.Background(), llm, g, "say hi") + if err != nil { + t.Fatalf("TranslateGrammar: %v", err) + } + plan, err := script.CompileGrammar(context.Background(), g, src) + if err != nil { + t.Fatalf("CompileGrammar: %v", err) + } + if len(plan.Nodes) != 1 { + t.Errorf("nodes = %d, want 1", len(plan.Nodes)) + } +} + +func TestGrammar_HistoricalVerbIsKnownNotUnknown(t *testing.T) { + g := script.Grammar() + llm := func(_ context.Context, _, _ string) (string, error) { + return `temporal static ( hf_summarize "x" )`, nil + } + src, _ := script.TranslateGrammar(context.Background(), llm, g, "summarize") + _, err := script.CompileGrammar(context.Background(), g, src) + if err == nil { + t.Fatal("hf_summarize on temporal should fail (not implemented there yet)") + } + var notImpl *script.NotImplementedOnBackendError + if !errors.As(err, ¬Impl) { + t.Errorf("expected NotImplementedOnBackendError, got %T: %v", err, err) + } +} diff --git a/pkg/script/submit.go b/pkg/script/submit.go index b9116ae..2145056 100644 --- a/pkg/script/submit.go +++ b/pkg/script/submit.go @@ -25,6 +25,22 @@ import ( "github.com/vinodhalaharvi/agentscript/pkg/script/resolved" ) +// TranslateGrammar is the discovery-driven translate entry: it takes a +// GrammarInfo (from Grammar()) instead of a registry, so a front end can +// pipe discovery straight into translation without ever touching or +// naming a registry. This is the "dumb pipe" path — loom calls Grammar(), +// passes the result here, and forwards the output. +func TranslateGrammar(ctx context.Context, complete CompleteFunc, g GrammarInfo, prose string) (Source, error) { + return Translate(ctx, complete, g.Registry, prose) +} + +// CompileGrammar compiles source against a GrammarInfo's registry. Pairs +// with TranslateGrammar so a discovery-driven front end never names a +// registry for either phase. +func CompileGrammar(ctx context.Context, g GrammarInfo, src Source) (sibyl.Plan, error) { + return Compile(ctx, g.Registry, src) +} + // Compile runs Parse >=> Resolve >=> Lower >=> Finalize >=> Validate, // producing a validated sibyl.Plan. It does not submit. //