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
11 changes: 9 additions & 2 deletions docs/dsl-to-sibyl-translator.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,22 @@ 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` |
| Finalize | `Arrow[Lowered, sibyl.Plan]` | **Merged** — `pkg/script/lower.go` |
| 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
Expand Down
16 changes: 10 additions & 6 deletions pkg/script/submit.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
129 changes: 129 additions & 0 deletions pkg/script/translate.go
Original file line number Diff line number Diff line change
@@ -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 ( <pipeline> )
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
}
}
122 changes: 122 additions & 0 deletions pkg/script/translate_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading