// 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 — adds a live "instruction" system // entry (agentrun.SystemEntry) for // this turn onward; repeatable // /system permission: — same, kind "permission" // /system budget: — 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 }