ask_user tool, prompt-JSON CLI repair loop, tool-loop research archive

- New ask_user core tool: mid-turn user question with option picking,
  refusing cleanly in autonomous mode, for background workers, and
  without a tty; system prompt points at it instead of prose questions.
- Structural loop control for prompt-JSON CLI backends (opencode,
  grok, claude, codex, gemini): shared cli_json_turn repairs a
  missing JSON envelope exactly once, then accepts prose; prompt
  contract hardened against narrate-without-calling.
- research/agentic-tool-loop-design: 74 sources plus 2 conclusions
  backing the loop-control decisions; INDEX updated to 6 subjects.
This commit is contained in:
2026-10-11 02:54:17 +02:00
parent 2e0e0547ed
commit afc88b8087
79 changed files with 2677 additions and 40 deletions
+104 -33
View File
@@ -410,7 +410,7 @@ SYSINFO_BINARIES = ("tmux", "git", "ffmpeg", "arecord", "sox", "whisper-cpp", "w
DEFAULT_SYSTEM = (
"You are tai, a professional autonomous assistant. You act with tools, verify results, and report concisely. "
"Rules: prefer the smallest change that solves the task; never silently ignore errors; ask when requirements are ambiguous; "
"Rules: prefer the smallest change that solves the task; never silently ignore errors; call ask_user when requirements are ambiguous (never end your turn with open questions in prose: one-shot runs exit and prose questions are never answered); "
"keep answers short and factual. Tools: shell and file tools for the local machine, web_search and web_fetch for the internet, "
"web_fetch handles every HTTP method with headers and bodies so never curl or wget through shell, "
"speak for voice output, recall to search past session memory. Memory: call remember whenever you learn durable facts, "
@@ -421,7 +421,8 @@ DEFAULT_SYSTEM = (
"Lazy tools: only everyday tools load by default; name any other tool or topic in your reply text and it becomes callable on your next step. "
"Bots: one identity holds many bots; a bot is only a system message plus its own history, all vault data is shared. @name routes one turn to another bot and files the exchange in both histories; /bot switches, create_bot makes bots. "
"Terminal: the user's question is very likely about what is visible in their terminal, especially right after a terminal capture or when launched with a single question straight from a shell; resolve vague references (this, that, it, the error) against that content, capture it with get_current_terminal_content instead of asking, and answer from what you see. "
"Retrieval: use search first for anything stored: one ranked full-text query across records, events, and audit plus graph expansion beats paging and guessing. Chain tool calls until the task is done."
"Retrieval: use search first for anything stored: one ranked full-text query across records, events, and audit plus graph expansion beats paging and guessing. Chain tool calls until the task is done. "
"Never narrate a planned action in text without emitting its tool call: text without tool calls ends your turn and the action will not happen."
)
MERGE_SYSTEM = (
"You maintain an AI assistant system prompt. You receive the current system prompt and one memory instruction. "
@@ -434,7 +435,7 @@ COMPACT_SYSTEM = (
"key decisions, files touched, errors and fixes, pending next steps. Output only the summary, no preamble."
)
AUTONOMOUS_NOTE = (
"Autonomous mode: never ask the user anything this session. When requirements are unclear, blocked, or need input, "
"Autonomous mode: never ask the user anything this session (the ask_user tool refuses, do not call it). When requirements are unclear, blocked, or need input, "
"research deeply with web_search, web_fetch, shell, and subagents, then decide and proceed. State assumptions briefly in your reply."
)
@@ -3038,16 +3039,14 @@ def opencode_prompt(messages, tools):
"You are the model behind another agent. The transcript above is the whole conversation; lines starting with TOOL <id> are results of tool calls that already ran. "
"Do not use any of your own tools and do not repeat a tool call whose result is already shown. "
"If the results answer the user, put the final answer in content with an empty tool_calls list; otherwise request the next tool calls from TOOLS. "
"An empty tool_calls list ends the turn at once, nothing else runs. "
"Never narrate a planned action without its tool call in the same reply. "
"Reply with one JSON object only, no code fence, matching this schema: "
)
return muse_prompt(messages, tools) + "\n\n" + rules + schema
def opencode_parse(text):
text = (text or "").strip()
fenced = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.S)
if fenced:
text = fenced.group(1)
def opencode_find_answer(text):
# Scan every "{" last-first and decode one value at each spot: free
# models often echo the prompt schema or tool JSON before the real
# answer, and a first-"{"-to-last-"}" slice spans both into garbage.
@@ -3061,7 +3060,23 @@ def opencode_parse(text):
continue
if isinstance(answer, dict) and ("content" in answer or "tool_calls" in answer):
return answer
return {"content": text, "tool_calls": []}
return None
def opencode_parse(text):
text = (text or "").strip()
fenced = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.S)
if fenced:
text = fenced.group(1)
return opencode_find_answer(text) or {"content": text, "tool_calls": []}
def cli_json_repair_prompt(messages, tools, bad_text):
return opencode_prompt(messages, tools) + (
"\n\nYour previous reply contained no JSON object, so no tool ran and the turn is about to end. "
"Reply now with exactly one JSON object matching the schema above and no other text. "
"Previous reply for reference:\n" + (bad_text or "").strip()[:2000]
)
def opencode_parse_events(stdout_text, returncode, stderr_text):
@@ -4433,42 +4448,44 @@ class ChatClient:
muse_transact(home, commit)
return self.muse_reply(answer, label)
def opencode_turn(self, messages, tools, stream_sink, label, model, timeout=HTTP_TIMEOUT):
answer = opencode_parse(opencode_run(model, opencode_prompt(messages, tools), timeout))
def cli_json_turn(self, label, messages, tools, stream_sink, timeout, run, parse, raw_text):
# Shared by the prompt-JSON CLI backends (opencode, grok, claude,
# codex, gemini): the model must answer with one JSON object, and a
# reply with no JSON envelope while tools are offered is a structural
# contract miss, never a verdict on the prose itself. Repair once with
# an explicit correction prompt, then accept prose as the final answer.
# A failed repair keeps the first answer: it was already usable.
raw = run(opencode_prompt(messages, tools), timeout)
answer = parse(raw)
text = raw_text(raw)
if tools and text is not None and opencode_find_answer(text) is None:
try:
raw = run(cli_json_repair_prompt(messages, tools, text), timeout)
except BackendError:
pass
else:
answer = parse(raw)
if stream_sink is not None and answer.get("content"):
stream_sink(answer["content"])
return self.muse_reply(answer, label)
def opencode_turn(self, messages, tools, stream_sink, label, model, timeout=HTTP_TIMEOUT):
return self.cli_json_turn(label, messages, tools, stream_sink, timeout, lambda prompt, secs: opencode_run(model, prompt, secs), opencode_parse, lambda raw: raw)
def box_opencode_turn(self, messages, tools, stream_sink, label, model, engine, timeout=HTTP_TIMEOUT):
answer = opencode_parse(box_opencode_run(engine, model, opencode_prompt(messages, tools), timeout))
if stream_sink is not None and answer.get("content"):
stream_sink(answer["content"])
return self.muse_reply(answer, label + " (box)")
return self.cli_json_turn(label + " (box)", messages, tools, stream_sink, timeout, lambda prompt, secs: box_opencode_run(engine, model, prompt, secs), opencode_parse, lambda raw: raw)
def grok_turn(self, messages, tools, stream_sink, label, timeout=HTTP_TIMEOUT):
answer = grok_parse_output(grok_run(opencode_prompt(messages, tools), timeout))
if stream_sink is not None and answer.get("content"):
stream_sink(answer["content"])
return self.muse_reply(answer, label)
return self.cli_json_turn(label, messages, tools, stream_sink, timeout, grok_run, grok_parse_output, lambda raw: raw)
def claude_turn(self, messages, tools, stream_sink, label, timeout=HTTP_TIMEOUT):
answer = claude_parse_output(claude_run(opencode_prompt(messages, tools), timeout))
if stream_sink is not None and answer.get("content"):
stream_sink(answer["content"])
return self.muse_reply(answer, label)
return self.cli_json_turn(label, messages, tools, stream_sink, timeout, claude_run, claude_parse_output, lambda raw: raw)
def codex_turn(self, messages, tools, stream_sink, label, timeout=HTTP_TIMEOUT):
stdout, outfile_text, used_schema = codex_run(opencode_prompt(messages, tools), timeout)
answer = codex_parse_output(stdout, outfile_text, used_schema)
if stream_sink is not None and answer.get("content"):
stream_sink(answer["content"])
return self.muse_reply(answer, label)
return self.cli_json_turn(label, messages, tools, stream_sink, timeout, codex_run, lambda raw: codex_parse_output(raw[0], raw[1], raw[2]), lambda raw: raw[1] if raw[1].strip() else raw[0])
def gemini_turn(self, messages, tools, stream_sink, label, timeout=HTTP_TIMEOUT):
answer = gemini_parse_output(gemini_run(opencode_prompt(messages, tools), timeout))
if stream_sink is not None and answer.get("content"):
stream_sink(answer["content"])
return self.muse_reply(answer, label)
return self.cli_json_turn(label, messages, tools, stream_sink, timeout, gemini_run, gemini_parse_output, lambda raw: raw)
def muse_reply(self, answer, label):
calls = []
@@ -4867,6 +4884,7 @@ TOOL_SCHEMAS = [
tool_schema("plexer_status", "Only available when a tai-managed terminal session is active. Reports which backend is managing the current session (tai's own native pty wrapper, or tmux) and backend-specific details (pid/socket/log, or pane id).", {}, []),
tool_schema("fork", "Spawn a background subagent with its own context that works while you continue. Returns an agent id immediately. Collect its summarized result with poll. Subagents get a smaller step budget and a time limit.", {"task": {"type": "string"}, "timeout": {"type": "integer"}, "profile": {"type": "string"}}, ["task"]),
tool_schema("poll", "Collect a background subagent result by id. Waits up to wait seconds, then reports running or the result.", {"id": {"type": "integer"}, "wait": {"type": "integer"}}, ["id"]),
tool_schema("ask_user", "Ask the user one question mid-turn and wait for their answer. Use this instead of ending your turn with questions in prose: an empty tool_calls list ends the turn at once and one-shot runs then exit, so prose questions are never answered. Pass the question plus optional options; the user picks an option (by number or text) or types free input, and their answer comes back as the tool result. Only for real blockers a human must decide; in autonomous mode and for background workers it is unavailable, so decide yourself and state the assumption.", {"question": {"type": "string"}, "options": {"type": "array"}}, ["question"]),
tool_schema("sysinfo", "Inspect the host machine where tai runs. Runs os, python, venv, root, container, binaries, cpu, and disk checks in parallel and reports each with timing. Pass checks to run a named subset.", {"checks": {"type": "array"}}, []),
tool_schema("create_skill", "Create a new agent skill by name from a brief. Runs a dedicated deep-research worker on this machine (it calls sysinfo first, then researches with web search and shell until it has verified information) and writes SKILL.md plus supporting files into the skill directory. Runs synchronously and can take many minutes. Scope project writes under ./.tai/skills (the default), scope home under ~/.tai/skills.", {"name": {"type": "string"}, "brief": {"type": "string"}, "scope": {"type": "string"}, "timeout": {"type": "integer"}}, ["name", "brief"]),
tool_schema("install_skill", "Install a skill that already exists somewhere else, instead of building one from scratch. source can be a local directory containing SKILL.md (or one subdirectory that does), a bare SKILL.md file, a local .zip, or a git URL (needs git installed; most sources need nothing beyond the standard library). Validates the SKILL.md frontmatter, then lands it under tai's own skill directories: copy vendors a private snapshot, symlink points at the source in place (the default when the source already sits in a recognized skills folder like .claude/skills or .agents/skills, so there is one canonical copy instead of a drifting duplicate). Scope defaults like create_skill: project when the cwd looks like a project, else home. Overwriting an existing skill of the same name asks the user first unless force is set.", {"source": {"type": "string"}, "name": {"type": "string"}, "scope": {"type": "string"}, "mode": {"type": "string"}, "force": {"type": "boolean"}}, ["source"]),
@@ -4893,7 +4911,9 @@ TOOL_SCHEMAS = [
]
CORE_TOOLS = ("shell", "read_file", "write_file", "remember", "recall", "load_skill", "fork", "poll")
CORE_TOOLS = ("shell", "read_file", "write_file", "remember", "recall", "load_skill", "fork", "poll", "ask_user")
ASK_USER_MAX_OPTIONS = 12
ASK_USER_MAX_ANSWER = 2000
LAZY_NAME_STOP = ("get", "set", "list", "add")
# Tools gated on top of the lazy keyword match: a gate must pass before a tool can even be
@@ -4932,6 +4952,7 @@ TOOL_TAGS = {
"plexer_status": ("plexer", "terminal", "tmux", "status", "session"),
"fork": ("fork", "subagent", "background", "parallel", "delegate", "spawn"),
"poll": ("poll", "collect"),
"ask_user": ("ask", "question", "clarify", "confirm", "choose", "answer", "input"),
"sysinfo": ("sysinfo", "system", "host", "machine", "hardware", "specs", "installed", "python", "container"),
"create_skill": ("skill", "create", "author", "blueprint"),
"install_skill": ("skill", "install", "import", "adopt", "copy", "symlink"),
@@ -5683,6 +5704,7 @@ class Tools:
"plexer_status": self.run_plexer_status,
"fork": self.run_fork,
"poll": self.run_poll,
"ask_user": self.run_ask_user,
"sysinfo": self.run_sysinfo,
"create_skill": self.run_create_skill,
"install_skill": self.run_install_skill,
@@ -6348,6 +6370,55 @@ class Tools:
return "[%s] %s" % (status, spilled_view(full, spilled, len(full)))
return "[%s] %s" % (status, text)
def run_ask_user(self, args):
question = str(args.get("question") or "").strip()
if not question:
return "error: question must be a non-empty string"
raw_options = args.get("options") or []
if not isinstance(raw_options, list):
return "error: options must be a list of strings"
options = []
for item in raw_options[:ASK_USER_MAX_OPTIONS]:
if isinstance(item, dict):
label = item.get("label", item.get("value", ""))
else:
label = item
label = str(label or "").strip().replace("\n", " ")
if label:
options.append(label[:200])
if getattr(self.app, "auto", False):
return "ask_user unavailable in autonomous mode: decide yourself and state the assumption"
if not getattr(self.app, "persist", True) or getattr(self.app, "depth", 0) > 0:
return "ask_user unavailable to background workers: decide yourself and state the assumption"
if not sys.stdin.isatty():
return "error: no interactive user on this stdin, decide yourself and state the assumption"
print(paint("?- " + question, Ansi.CYAN, Ansi.BOLD))
for pos, label in enumerate(options, 1):
print(paint(" %d) %s" % (pos, label), Ansi.DIM))
try:
if options:
raw = input(paint("answer [1-%d, option text, or free input] " % len(options), Ansi.YELLOW))
else:
raw = input(paint("answer (free input, empty skips) ", Ansi.YELLOW))
except (EOFError, KeyboardInterrupt):
return "user gave no answer (input closed): proceed with your best assumption"
self.app.user_stepped_in = True
answer = raw.strip()
if not answer:
return "user gave no answer (empty): proceed with your best assumption"
if len(answer) > ASK_USER_MAX_ANSWER:
answer = answer[:ASK_USER_MAX_ANSWER] + "...[truncated]"
if options:
if answer.isdigit():
pos = int(answer)
if 1 <= pos <= len(options):
return "user chose option %d: %s" % (pos, options[pos - 1])
lowered = answer.lower()
for pos, label in enumerate(options, 1):
if label.lower() == lowered:
return "user chose option %d: %s" % (pos, label)
return "user answered: %s" % answer
def run_sysinfo(self, args):
names = args.get("checks")
if names is None: