From b7b347ca73e4d428b4475259614110d99fdc7350 Mon Sep 17 00:00:00 2001 From: vinodhalaharvi-claude Date: Thu, 21 May 2026 12:32:40 +0000 Subject: [PATCH] =?UTF-8?q?script:=20add=20Translate=20(prose=20->=20DSL)?= =?UTF-8?q?=20=E2=80=94=20agentscript=20owns=20the=20grammar=20prompt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves prose->DSL ownership into pkg/script, where the grammar (Parse) and the builtin registry (Resolve) live. A prose-driven front end like loom should not carry its own copy of the grammar rules and builtin list — it drifts the moment the grammar changes. Now the front end hands over prose and gets back a validated Plan, knowing nothing about >=>, <*>, or which commands exist. New (pkg/script/translate.go): - CompleteFunc — the LLM seam (matches sibyl's agent.CompleteFunc, so a sibyl client plugs in, but this package depends on nothing in sibyl for translation and picks no provider / reads no API key). - Translate(ctx, complete, reg, prose) -> Source — calls the LLM with the grammar prompt, strips fences/stray prose, returns DSL. Does not compile; pass to Compile for that. - BuildPrompt(reg) -> string — exported so callers can inspect/log the exact instruction and tests can assert it. Lists reg.Names() (the LLM may use only commands that exist) and states the conservative composition discipline: sequential >=> by default, parallel <*> only for an unambiguous flat list. The grammar is permissive; the translation is conservative — inferring parallelism the user didn't express is the risky case. - cleanDSL — conservative fence/prose stripping; does NOT 'fix' the DSL, so malformed output fails loudly at Compile. New (pkg/script/submit.go): - TranslateAndCompile(ctx, complete, reg, prose) -> sibyl.Plan — the full front-half a prose caller wants: Translate + Compile in one call. Both translation failure (LLM error) and compile failure (unknown builtin, bad arity, malformed graph) surface here, before anything executes. This supersedes loom's own translator.go (which carried a duplicate grammar prompt in the wrong repo) — loom will switch to calling script.Translate next. It also stands distinct from the LEGACY internal/agentscript/translator.go, which targets the old grammar (-> pipes, parallel {}) and the old in-process Runtime via Gemini; that path is left untouched here (its own consumer, cmd/agentscript, still needs it). Whether to retire the legacy runtime entirely is a separate decision / PR. Tests (translate_test.go, 11): BuildPrompt lists builtins + uses >=> (not the legacy ->), nil-registry safety, fence stripping, surrounding- prose stripping, nil-LLM and LLM-error handling, TranslateAndCompile happy path (prose -> 2-node validated plan), and the SAFETY NET — teleport "mars" (unknown command) and 'this is not agentscript' (malformed) both fail at compile so nothing executes. Memo updated: Translate added to the phase table; pipeline note now covers TranslateAndCompile and the prose->DSL ownership. No new dependencies (Translate uses only registry + stdlib; the LLM is injected). All CI steps pass: go vet -structtag=false, gofmt, staticcheck, go test -race ./pkg/script/..., go build ./.... --- docs/dsl-to-sibyl-translator.md | 11 ++- pkg/script/submit.go | 16 ++-- pkg/script/translate.go | 129 ++++++++++++++++++++++++++++++++ pkg/script/translate_test.go | 122 ++++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+), 8 deletions(-) create mode 100644 pkg/script/translate.go create mode 100644 pkg/script/translate_test.go diff --git a/docs/dsl-to-sibyl-translator.md b/docs/dsl-to-sibyl-translator.md index 2810c97..365b4fe 100644 --- a/docs/dsl-to-sibyl-translator.md +++ b/docs/dsl-to-sibyl-translator.md @@ -12,6 +12,7 @@ The translator is being built in slices (see §14). Current state: | Phase | Arrow | Status | |-------|-------|--------| +| Translate | `Arrow[prose, Source]` | **Merged** — `pkg/script/translate.go` (LLM seam) | | Parse | `Arrow[Source, AST]` | **Merged** — `pkg/script/parse.go` | | Resolve | `Arrow[AST, ResolvedAST]` | **Merged** — `pkg/script/resolve.go` + `registry/` | | Lower | `Arrow[ResolvedAST, Lowered]` | **Merged** — `pkg/script/lower.go` | @@ -19,8 +20,14 @@ The translator is being built in slices (see §14). Current state: | Validate | `Arrow[sibyl.Plan, sibyl.Plan]` | **Merged** — `pkg/script/lower.go` | | Submit | `Arrow[sibyl.Plan, WorkflowHandle]` | **Merged** — `pkg/script/submit.go` | -The full pipeline (`Compile` = Parse..Validate, `Run` = Compile + Submit) -is in `pkg/script/submit.go`, importable from outside the module. The +The full pipeline (`Compile` = Parse..Validate, `Run` = Compile + Submit, +`TranslateAndCompile` = Translate + Compile) is in `pkg/script/`, +importable from outside the module. `Translate` is the prose→DSL phase: +it owns the grammar prompt (built from `registry.Names()`, so available +commands are always the real current set) and takes the LLM as an +injected `CompleteFunc` seam — the package picks no provider. A +prose-driven front end (loom) hands over prose and gets back a validated +Plan, carrying no grammar knowledge of its own. The lowering target is Sibyl's serializable `Plan` (a DAG of named-activity references) executed by the generic `PlanWorkflow` — not the removed in-process closure DAG. The first builtin, `echo`, binds to Sibyl's diff --git a/pkg/script/submit.go b/pkg/script/submit.go index bdc2c92..7bf1da2 100644 --- a/pkg/script/submit.go +++ b/pkg/script/submit.go @@ -69,12 +69,16 @@ func SubmitWith(c client.Client, taskQueue string) func(context.Context, sibyl.P } } -// Run compiles and submits source in one call, returning the workflow -// handle. The caller awaits handle.Get for the PlanResult. -func Run(ctx context.Context, reg *registry.Registry, c client.Client, src Source, taskQueue string) (client.WorkflowRun, error) { - plan, err := Compile(ctx, reg, src) +// TranslateAndCompile is the full front-half for a prose-driven caller: +// prose → DSL (via the LLM) → validated Plan. It is Translate followed by +// Compile, the two phases a front end like loom runs before submitting. +// A translation failure (LLM error) and a compile failure (unknown +// builtin, bad arity, malformed graph) are both returned here, before +// anything executes. +func TranslateAndCompile(ctx context.Context, complete CompleteFunc, reg *registry.Registry, prose string) (sibyl.Plan, error) { + src, err := Translate(ctx, complete, reg, prose) if err != nil { - return nil, err + return sibyl.Plan{}, err } - return Submit(ctx, c, plan, "", taskQueue) + return Compile(ctx, reg, src) } diff --git a/pkg/script/translate.go b/pkg/script/translate.go new file mode 100644 index 0000000..8fbc16f --- /dev/null +++ b/pkg/script/translate.go @@ -0,0 +1,129 @@ +// Package script — translate.go is the prose→DSL phase: it turns a +// natural-language request into an AgentScript program using an injected +// LLM, so a front end (loom, a CLI, anything) can hand over prose and +// get back source the rest of the pipeline compiles. +// +// This lives in pkg/script, next to the grammar (Parse) and the registry +// (Resolve), on purpose: the translation prompt must stay in lockstep +// with the grammar the parser accepts and the builtins the registry +// defines. A front end should not carry its own copy of the grammar +// rules — it would drift the moment the grammar or builtins change. The +// prompt here reads reg.Names() so the available commands are always the +// real, current set. +// +// The LLM is a seam (CompleteFunc), not a dependency: this package does +// not pick a provider or read an API key. The caller passes whatever +// LLM it has (Anthropic, Gemini, a fake in tests). +package script + +import ( + "context" + "fmt" + "strings" + + "github.com/vinodhalaharvi/agentscript/pkg/script/registry" +) + +// CompleteFunc is the LLM seam: given a system prompt and a user +// message, return the completion. It matches Sibyl's agent.CompleteFunc, +// so a Sibyl LLM client plugs in directly, but this package depends on +// nothing in Sibyl for translation. +type CompleteFunc func(ctx context.Context, systemPrompt, userMessage string) (string, error) + +// Translate converts natural-language prose into an AgentScript program +// (Source) using the given LLM and registry. It does not compile or +// validate — pass the result to Compile for that. The returned Source is +// the LLM's output with code fences and stray prose stripped. +// +// The composition discipline is encoded in the prompt: emit sequential +// pipelines (>=>) by default, and parallel fan-out (<*>) only for an +// unambiguous flat list of independent actions. Inferring parallelism a +// user did not clearly express is the risky case, so the prompt biases +// toward sequential. The grammar is permissive; the translation is +// conservative. +func Translate(ctx context.Context, complete CompleteFunc, reg *registry.Registry, prose string) (Source, error) { + if complete == nil { + return "", fmt.Errorf("translate: no LLM (CompleteFunc) provided") + } + out, err := complete(ctx, BuildPrompt(reg), prose) + if err != nil { + return "", fmt.Errorf("translate: LLM call failed: %w", err) + } + return Source(cleanDSL(out)), nil +} + +// BuildPrompt assembles the system prompt the LLM follows. It is exported +// so callers can inspect or log the exact instruction, and so tests can +// assert its contents. It lists the registry's builtins (the LLM may use +// only commands that exist) and states the composition rules. +func BuildPrompt(reg *registry.Registry) string { + var names []string + if reg != nil { + names = reg.Names() + } + available := "(none)" + if len(names) > 0 { + available = strings.Join(names, ", ") + } + + return `You translate a user's request into a small pipeline language called AgentScript. Output ONLY the AgentScript program — no prose, no explanation, no code fences. + +GRAMMAR +A program is a single block: + temporal static ( ) +A pipeline is one or more commands joined by >=> (sequential, left output feeds right): + command "arg" >=> command "arg" >=> command +Parallel fan-out exists as <*> inside parentheses, but use it ONLY when the request is an explicit, unambiguous flat list of independent things to do at once. When in doubt, use sequential >=> . + +AVAILABLE COMMANDS (you may use ONLY these — never invent a command): + ` + available + ` + +RULES +1. Output exactly one block: temporal static ( ... ). Nothing else. +2. Use only commands from the AVAILABLE COMMANDS list. If the request needs a command that does not exist, choose the closest available command; do not invent names. +3. Prefer sequential >=> . Use <*> only for a clear list of independent parallel actions. +4. String arguments are double-quoted. Pass the user's intent as the argument text. +5. Keep it minimal — the smallest pipeline that satisfies the request. + +EXAMPLE +Request: say hello to the team +Output: temporal static ( echo "hello to the team" )` +} + +// cleanDSL strips common LLM wrapping (code fences, leading/trailing +// prose) so the result is just the AgentScript program. It is +// deliberately conservative: it removes fences and trims, but does not +// try to "fix" the DSL — malformed output should fail loudly at Compile, +// not be silently patched here. +func cleanDSL(s string) string { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "```") { + if nl := strings.IndexByte(s, '\n'); nl != -1 { + s = s[nl+1:] + } + s = strings.TrimSuffix(strings.TrimSpace(s), "```") + } + s = strings.TrimSpace(s) + if i := indexOfBlockStart(s); i > 0 { + s = s[i:] + } + if j := strings.LastIndexByte(s, ')'); j != -1 && j < len(s)-1 { + s = s[:j+1] + } + return strings.TrimSpace(s) +} + +func indexOfBlockStart(s string) int { + t := strings.Index(s, "temporal") + m := strings.Index(s, "memory") + switch { + case t == -1: + return m + case m == -1: + return t + case t < m: + return t + default: + return m + } +} diff --git a/pkg/script/translate_test.go b/pkg/script/translate_test.go new file mode 100644 index 0000000..58c9b0b --- /dev/null +++ b/pkg/script/translate_test.go @@ -0,0 +1,122 @@ +package script_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/vinodhalaharvi/agentscript/pkg/script" +) + +// stubLLM returns a CompleteFunc that always yields the given output. +func stubLLM(out string) script.CompleteFunc { + return func(_ context.Context, _, _ string) (string, error) { return out, nil } +} + +func TestBuildPrompt_ListsBuiltins(t *testing.T) { + p := script.BuildPrompt(script.DefaultRegistry()) + if !strings.Contains(p, "echo") { + t.Error("prompt should list the echo builtin") + } + if !strings.Contains(p, ">=>") { + t.Error("prompt should describe the >=> sequential operator") + } + // Must NOT teach the old grammar. + if strings.Contains(p, "->") && !strings.Contains(p, ">=>") { + t.Error("prompt should not use the legacy -> pipe") + } +} + +func TestBuildPrompt_NilRegistry(t *testing.T) { + // Should not panic; lists nothing. + p := script.BuildPrompt(nil) + if !strings.Contains(p, "(none)") { + t.Error("nil registry should yield (none) for available commands") + } +} + +func TestTranslate_StripsFences(t *testing.T) { + llm := stubLLM("```agentscript\ntemporal static ( echo \"hi\" )\n```") + src, err := script.Translate(context.Background(), llm, script.DefaultRegistry(), "say hi") + if err != nil { + t.Fatalf("Translate: %v", err) + } + if strings.Contains(string(src), "```") { + t.Errorf("fences not stripped: %q", src) + } + if !strings.HasPrefix(string(src), "temporal static") { + t.Errorf("Source = %q, want it to start with the block", src) + } +} + +func TestTranslate_StripsSurroundingProse(t *testing.T) { + llm := stubLLM("Sure! Here you go:\ntemporal static ( echo \"hi\" )\nHope that helps!") + src, err := script.Translate(context.Background(), llm, script.DefaultRegistry(), "say hi") + if err != nil { + t.Fatalf("Translate: %v", err) + } + if !strings.HasPrefix(string(src), "temporal static") { + t.Errorf("leading prose not stripped: %q", src) + } + if strings.Contains(string(src), "Hope that helps") { + t.Errorf("trailing prose not stripped: %q", src) + } +} + +func TestTranslate_NilLLM(t *testing.T) { + _, err := script.Translate(context.Background(), nil, script.DefaultRegistry(), "x") + if err == nil { + t.Fatal("expected error for nil CompleteFunc") + } +} + +func TestTranslate_LLMError(t *testing.T) { + llm := func(_ context.Context, _, _ string) (string, error) { + return "", errors.New("network down") + } + _, err := script.Translate(context.Background(), llm, script.DefaultRegistry(), "x") + if err == nil { + t.Fatal("expected error when LLM fails") + } +} + +// === TranslateAndCompile: the full prose → validated Plan path ============ + +func TestTranslateAndCompile_HappyPath(t *testing.T) { + llm := stubLLM(`temporal static ( echo "hello" >=> echo )`) + plan, err := script.TranslateAndCompile(context.Background(), llm, script.DefaultRegistry(), "say hello then echo it") + if err != nil { + t.Fatalf("TranslateAndCompile: %v", err) + } + if len(plan.Nodes) != 2 { + t.Fatalf("nodes = %d, want 2", len(plan.Nodes)) + } + if err := plan.Validate(); err != nil { + t.Errorf("produced plan should be valid: %v", err) + } +} + +// The safety net: if the LLM emits a command that isn't a builtin, +// TranslateAndCompile must fail at the compile step — nothing executes. +func TestTranslateAndCompile_RejectsUnknownCommand(t *testing.T) { + llm := stubLLM(`temporal static ( teleport "mars" )`) + _, err := script.TranslateAndCompile(context.Background(), llm, script.DefaultRegistry(), "teleport me") + if err == nil { + t.Fatal("SAFETY NET FAILED: unknown command compiled without error") + } + // It should be a resolve/compile error, surfaced clearly. + if !strings.Contains(strings.ToLower(err.Error()), "teleport") && + !strings.Contains(strings.ToLower(err.Error()), "unknown") && + !strings.Contains(strings.ToLower(err.Error()), "builtin") { + t.Errorf("error should point at the unknown command, got: %v", err) + } +} + +func TestTranslateAndCompile_RejectsMalformed(t *testing.T) { + llm := stubLLM(`this is not agentscript at all`) + _, err := script.TranslateAndCompile(context.Background(), llm, script.DefaultRegistry(), "garbage") + if err == nil { + t.Fatal("malformed DSL should fail to compile") + } +}