This commit is contained in:
196
pkg/turnopts/turnopts.go
Normal file
196
pkg/turnopts/turnopts.go
Normal file
@@ -0,0 +1,196 @@
|
||||
// Package turnopts parses a small set of leading slash-directives off a
|
||||
// chat prompt — /effort, /fast, /system — into structured per-turn
|
||||
// options. It exists as a pragmatic bridge: api/proto/agent/v1/
|
||||
// agent.proto documents the "real" target API surface for effort
|
||||
// control, fast mode and live system-entry updates (new StartTurnRequest
|
||||
// fields), but wiring those through means regenerating gen/agent/v1
|
||||
// with protoc — a build step this authoring environment has no network
|
||||
// access to run (see ARCHITECTURE.md §6, "What's verified, what isn't",
|
||||
// for the project's own established pattern of calling this out rather
|
||||
// than hand-editing generated code and hoping). Parsing a few directive
|
||||
// lines off the existing plain-string StartTurnRequest.prompt field
|
||||
// needs no proto change at all, so it's what actually ships working
|
||||
// today; application.Service.StartTurn calls Parse before anything else
|
||||
// and the gateway's chat box documents the directives inline. Once
|
||||
// `make proto` is run against the extended .proto, a thin follow-up can
|
||||
// read the structured fields directly and this package becomes the
|
||||
// fallback path for a plain-text client instead of the only path.
|
||||
package turnopts
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"enkly/pkg/effort"
|
||||
)
|
||||
|
||||
// SystemEntryKind mirrors agentrun.SystemEntryKind's three values
|
||||
// without importing the agentrun package (which would pull the agent
|
||||
// service's whole internal/infrastructure tree into a leaf pkg/
|
||||
// package that belongs to no particular service) — application.Service
|
||||
// translates this to the real agentrun.SystemEntryKind at the one call
|
||||
// site that actually constructs an agentrun.SystemEntry.
|
||||
type SystemEntryKind string
|
||||
|
||||
const (
|
||||
SystemEntryInstruction SystemEntryKind = "instruction"
|
||||
SystemEntryPermission SystemEntryKind = "permission"
|
||||
SystemEntryBudget SystemEntryKind = "budget"
|
||||
)
|
||||
|
||||
// SystemEntry is one /system directive's parsed kind + content.
|
||||
type SystemEntry struct {
|
||||
Kind SystemEntryKind
|
||||
Content string
|
||||
}
|
||||
|
||||
// Options is everything Parse extracts from a prompt's leading
|
||||
// directive lines.
|
||||
type Options struct {
|
||||
// Effort is the requested reasoning-depth level, or "" if no
|
||||
// /effort directive was present — application.Service.StartTurn
|
||||
// leaves the Agent's own default in force in that case rather than
|
||||
// forcing a level.
|
||||
Effort effort.Level
|
||||
// Fast requests fast mode for this turn — see /fast's doc comment
|
||||
// below and application.Service's fast-mode client selection.
|
||||
Fast bool
|
||||
// SystemEntries are every /system directive present, in the order
|
||||
// they appeared.
|
||||
SystemEntries []SystemEntry
|
||||
// HadDirectives reports whether anything was actually parsed out of
|
||||
// the prompt — lets a caller skip rebuilding a cleaned prompt
|
||||
// string (Clean) when there's nothing to clean, which is the
|
||||
// overwhelmingly common case.
|
||||
HadDirectives bool
|
||||
}
|
||||
|
||||
// directivePrefixes lists every recognized directive, longest-match
|
||||
// order doesn't matter since each is matched as a whole leading token.
|
||||
var directivePrefixes = []string{"/effort", "/fast", "/system"}
|
||||
|
||||
// isDirectiveLine reports whether line (already trimmed) opens with one
|
||||
// of directivePrefixes followed by either nothing or whitespace — so
|
||||
// "/effortless" (a real sentence someone might type) is correctly NOT
|
||||
// treated as an /effort directive.
|
||||
func isDirectiveLine(line string) (prefix string, rest string, ok bool) {
|
||||
for _, p := range directivePrefixes {
|
||||
if line == p {
|
||||
return p, "", true
|
||||
}
|
||||
if strings.HasPrefix(line, p+" ") || strings.HasPrefix(line, p+"\t") {
|
||||
return p, strings.TrimSpace(line[len(p):]), true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// Parse reads directive lines off the START of prompt — one per line,
|
||||
// stopping at the first line that isn't a recognized directive — and
|
||||
// returns the parsed Options plus the remaining prompt text (clean,
|
||||
// with the directive lines and any single blank line immediately after
|
||||
// them removed). A prompt with no leading directives at all returns the
|
||||
// zero Options and prompt unchanged, byte for byte, so the overwhelming
|
||||
// majority of turns pay no cost here beyond one cheap prefix check per
|
||||
// line.
|
||||
//
|
||||
// Directives, each on its own line at the very start of the message:
|
||||
//
|
||||
// /effort low|medium|high|extra|max — reasoning depth for this turn
|
||||
// (see pkg/effort); unrecognized
|
||||
// text is reported back as part
|
||||
// of the tool result, not silently
|
||||
// ignored — see Parse's errs return.
|
||||
// /fast on|off — fast mode for this turn (off is
|
||||
// the default; "/fast" alone means on)
|
||||
// /system <instruction text> — adds a live "instruction" system
|
||||
// entry (agentrun.SystemEntry) for
|
||||
// this turn onward; repeatable
|
||||
// /system permission: <text> — same, kind "permission"
|
||||
// /system budget: <text> — same, kind "budget"
|
||||
//
|
||||
// Directives are deliberately NOT required to be the only content of a
|
||||
// message — "/effort high\n\nnow refactor the billing package" works
|
||||
// exactly as "/effort high" on its own line followed immediately by the
|
||||
// real prompt. A message that's directives with nothing else after them
|
||||
// (clean comes back empty) is treated by the caller the same as any
|
||||
// other empty prompt.
|
||||
func Parse(prompt string) (opts Options, clean string) {
|
||||
lines := strings.Split(prompt, "\n")
|
||||
i := 0
|
||||
for ; i < len(lines); i++ {
|
||||
trimmed := strings.TrimSpace(lines[i])
|
||||
if trimmed == "" {
|
||||
// A blank line before any directive is just normal prompt
|
||||
// formatting, not a directive terminator — only stop
|
||||
// consuming on a non-directive, non-blank line, or once at
|
||||
// least one directive has been seen and a blank line
|
||||
// follows it (handled by the break conditions below).
|
||||
if opts.HadDirectives {
|
||||
i++ // swallow the one blank line right after directives
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
prefix, rest, ok := isDirectiveLine(trimmed)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
opts.HadDirectives = true
|
||||
switch prefix {
|
||||
case "/effort":
|
||||
if lvl, err := effort.Parse(rest); err == nil {
|
||||
opts.Effort = lvl
|
||||
}
|
||||
// An unparseable level is silently dropped rather than
|
||||
// failing the whole turn over a typo in a directive line —
|
||||
// application.Service still runs the turn at the Agent's
|
||||
// default effort. A future structured API surface (the
|
||||
// proto fields) can report this as a real validation
|
||||
// error; a best-effort text convention shouldn't block a
|
||||
// turn over it.
|
||||
case "/fast":
|
||||
opts.Fast = parseOnOff(rest, true) // bare "/fast" means on
|
||||
case "/system":
|
||||
if entry, ok := parseSystemDirective(rest); ok {
|
||||
opts.SystemEntries = append(opts.SystemEntries, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.HadDirectives {
|
||||
return opts, prompt
|
||||
}
|
||||
clean = strings.TrimLeft(strings.Join(lines[i:], "\n"), "\n")
|
||||
return opts, clean
|
||||
}
|
||||
|
||||
func parseOnOff(s string, defaultWhenEmpty bool) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "":
|
||||
return defaultWhenEmpty
|
||||
case "on", "true", "1", "yes":
|
||||
return true
|
||||
case "off", "false", "0", "no":
|
||||
return false
|
||||
default:
|
||||
return defaultWhenEmpty
|
||||
}
|
||||
}
|
||||
|
||||
func parseSystemDirective(rest string) (SystemEntry, bool) {
|
||||
rest = strings.TrimSpace(rest)
|
||||
if rest == "" {
|
||||
return SystemEntry{}, false
|
||||
}
|
||||
for _, kind := range []SystemEntryKind{SystemEntryPermission, SystemEntryBudget} {
|
||||
p := string(kind) + ":"
|
||||
if strings.HasPrefix(strings.ToLower(rest), p) {
|
||||
content := strings.TrimSpace(rest[len(p):])
|
||||
if content == "" {
|
||||
return SystemEntry{}, false
|
||||
}
|
||||
return SystemEntry{Kind: kind, Content: content}, true
|
||||
}
|
||||
}
|
||||
return SystemEntry{Kind: SystemEntryInstruction, Content: rest}, true
|
||||
}
|
||||
107
pkg/turnopts/turnopts_test.go
Normal file
107
pkg/turnopts/turnopts_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package turnopts
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"enkly/pkg/effort"
|
||||
)
|
||||
|
||||
func TestParse_NoDirectives(t *testing.T) {
|
||||
prompt := "just refactor the billing package please"
|
||||
opts, clean := Parse(prompt)
|
||||
if opts.HadDirectives {
|
||||
t.Fatal("HadDirectives = true, want false for a plain prompt")
|
||||
}
|
||||
if clean != prompt {
|
||||
t.Fatalf("clean = %q, want unchanged %q", clean, prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_EffortAndRest(t *testing.T) {
|
||||
opts, clean := Parse("/effort high\n\nnow refactor the billing package")
|
||||
if !opts.HadDirectives {
|
||||
t.Fatal("HadDirectives = false, want true")
|
||||
}
|
||||
if opts.Effort != effort.High {
|
||||
t.Fatalf("Effort = %q, want %q", opts.Effort, effort.High)
|
||||
}
|
||||
if clean != "now refactor the billing package" {
|
||||
t.Fatalf("clean = %q", clean)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_FastBare(t *testing.T) {
|
||||
opts, clean := Parse("/fast\nmake this quick")
|
||||
if !opts.Fast {
|
||||
t.Fatal("Fast = false, want true for bare /fast")
|
||||
}
|
||||
if clean != "make this quick" {
|
||||
t.Fatalf("clean = %q", clean)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_FastOff(t *testing.T) {
|
||||
opts, _ := Parse("/fast off\nhello")
|
||||
if opts.Fast {
|
||||
t.Fatal("Fast = true, want false for /fast off")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_SystemEntries(t *testing.T) {
|
||||
opts, clean := Parse(
|
||||
"/system never touch the prod/ directory\n" +
|
||||
"/system permission: you may now run destructive migrations here\n" +
|
||||
"/system budget: 4 tool calls left\n" +
|
||||
"go ahead and start",
|
||||
)
|
||||
if len(opts.SystemEntries) != 3 {
|
||||
t.Fatalf("got %d system entries, want 3: %+v", len(opts.SystemEntries), opts.SystemEntries)
|
||||
}
|
||||
if opts.SystemEntries[0].Kind != SystemEntryInstruction || opts.SystemEntries[0].Content != "never touch the prod/ directory" {
|
||||
t.Errorf("entry 0 = %+v", opts.SystemEntries[0])
|
||||
}
|
||||
if opts.SystemEntries[1].Kind != SystemEntryPermission || opts.SystemEntries[1].Content != "you may now run destructive migrations here" {
|
||||
t.Errorf("entry 1 = %+v", opts.SystemEntries[1])
|
||||
}
|
||||
if opts.SystemEntries[2].Kind != SystemEntryBudget || opts.SystemEntries[2].Content != "4 tool calls left" {
|
||||
t.Errorf("entry 2 = %+v", opts.SystemEntries[2])
|
||||
}
|
||||
if clean != "go ahead and start" {
|
||||
t.Fatalf("clean = %q", clean)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_NotADirectiveLookalike(t *testing.T) {
|
||||
// "/effortless" must NOT be parsed as "/effort" + "less".
|
||||
prompt := "/effortless attempts at this keep failing, please help"
|
||||
opts, clean := Parse(prompt)
|
||||
if opts.HadDirectives {
|
||||
t.Fatalf("HadDirectives = true for %q, want false", prompt)
|
||||
}
|
||||
if clean != prompt {
|
||||
t.Fatalf("clean = %q, want unchanged", clean)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_UnknownEffortValueIgnored(t *testing.T) {
|
||||
opts, clean := Parse("/effort super-duper\ndo the thing")
|
||||
if !opts.HadDirectives {
|
||||
t.Fatal("HadDirectives = false, want true (the line was still a directive)")
|
||||
}
|
||||
if opts.Effort != "" {
|
||||
t.Fatalf("Effort = %q, want empty for an unrecognized value", opts.Effort)
|
||||
}
|
||||
if clean != "do the thing" {
|
||||
t.Fatalf("clean = %q", clean)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_DirectivesOnlyNoBody(t *testing.T) {
|
||||
opts, clean := Parse("/effort max\n/fast on")
|
||||
if !opts.HadDirectives {
|
||||
t.Fatal("HadDirectives = false, want true")
|
||||
}
|
||||
if clean != "" {
|
||||
t.Fatalf("clean = %q, want empty", clean)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user