Files
enkly-ai/pkg/turnopts/turnopts.go
enkly 79ce8dc4c0
Some checks failed
Go CI Pipeline / build_and_test (push) Failing after 22m0s
Init Project
2026-10-30 18:06:55 +01:00

197 lines
7.7 KiB
Go

// 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
}