197 lines
7.7 KiB
Go
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
|
|
}
|