Carry conversation history so "yes" / "do that" resolve

The driver interpreted each utterance in isolation (schemas + scene +
utterance only), so when WoodShop asked a clarifying question and the user
replied "yes", the next turn had no record of what was proposed and fell
back to "not sure what you'd like me to do".

- driver.interpret/handle now accept a rolling (utterance, reply) history;
  SYSTEM prompt gains a "Recent conversation" section instructing the model
  to execute the previously-proposed calls on affirmation.
- CLI main() keeps a history list across the loop.
- GUI Controller keeps a bounded self._history and threads it through
  run_command, appending each turn.
- tests: history render/window, prompt inclusion, handle + controller append.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
rob 2026-05-30 15:42:33 -03:00
parent 274e87e239
commit 60957ae4af
4 changed files with 92 additions and 7 deletions

View File

@ -24,6 +24,7 @@ import sys
TOOL_FILTER = "wood-*" # auto-discover every wood-* tool, no hardcoded list TOOL_FILTER = "wood-*" # auto-discover every wood-* tool, no hardcoded list
REASON_PROVIDER = "claude -p" # chosen for reliable structured tool-calling REASON_PROVIDER = "claude -p" # chosen for reliable structured tool-calling
_MAX_HISTORY = 6 # turns of recent conversation fed back for reference-resolution
# A board placed earlier in the SAME utterance is referenced as $1, $2, ... # A board placed earlier in the SAME utterance is referenced as $1, $2, ...
_SYMBOL = re.compile(r"\$(\d+)") _SYMBOL = re.compile(r"\$(\d+)")
@ -53,6 +54,13 @@ Tools (JSON schemas):
Current scene: Current scene:
{scene} {scene}
Recent conversation (oldest first) use it to resolve back-references like "yes",
"do that", "go ahead", or "the one you suggested". If your PREVIOUS turn proposed a
specific set of changes and the user now affirms ("yes" / "do it" / "go ahead"),
emit the full sequence of tool calls you proposed (read off the current scene for
real ids). Only ask again if the affirmation is genuinely ambiguous.
{history}
Rules: Rules:
- Respond with ONLY a JSON array. No prose, no markdown fences. - Respond with ONLY a JSON array. No prose, no markdown fences.
- Each element is {{"tool": "<name>", "args": {{...}}}}. - Each element is {{"tool": "<name>", "args": {{...}}}}.
@ -118,9 +126,21 @@ def _extract_calls(raw: str) -> list[dict] | None:
return None return None
def interpret(utterance: str, schemas: str, scene_text: str | None = None) -> list[dict]: def _render_history(history: list[tuple[str, str]] | None) -> str:
if not history:
return "(no prior turns)"
lines = []
for user, assistant in history[-_MAX_HISTORY:]:
lines.append(f'User: "{user}"')
lines.append(f"WoodShop: {assistant}")
return "\n".join(lines)
def interpret(utterance: str, schemas: str, scene_text: str | None = None,
history: list[tuple[str, str]] | None = None) -> list[dict]:
scene = scene_text if scene_text is not None else scene_summary() scene = scene_text if scene_text is not None else scene_summary()
prompt = SYSTEM.format(schemas=schemas, scene=scene, utterance=utterance) prompt = SYSTEM.format(schemas=schemas, scene=scene, utterance=utterance,
history=_render_history(history))
raw = _run(REASON_PROVIDER.split(), stdin=prompt) raw = _run(REASON_PROVIDER.split(), stdin=prompt)
calls = _extract_calls(raw) calls = _extract_calls(raw)
if calls is None: if calls is None:
@ -215,12 +235,15 @@ def summarize(calls: list[dict], messages: list[str]) -> str:
return ("Done — " + ", ".join(chunks) + ".") if chunks else "Done." return ("Done — " + ", ".join(chunks) + ".") if chunks else "Done."
def handle(utterance: str, schemas: str, voice: bool, verbose: bool) -> None: def handle(utterance: str, schemas: str, voice: bool, verbose: bool,
calls = interpret(utterance, schemas) history: list[tuple[str, str]] | None = None) -> None:
calls = interpret(utterance, schemas, history=history)
messages = dispatch(calls, verbose=verbose) messages = dispatch(calls, verbose=verbose)
full = " ".join(m for m in messages if m).strip() full = " ".join(m for m in messages if m).strip()
spoken = summarize(calls, messages) spoken = summarize(calls, messages)
print(f"WoodShop: {full or spoken}") print(f"WoodShop: {full or spoken}")
if history is not None:
history.append((utterance, spoken))
if voice: if voice:
speak(spoken) speak(spoken)
@ -256,6 +279,7 @@ def main(argv: list[str] | None = None) -> int:
return 0 return 0
print("WoodShop ready. Say things like 'place a 6 foot 2x4'. Ctrl-C to quit.") print("WoodShop ready. Say things like 'place a 6 foot 2x4'. Ctrl-C to quit.")
history: list[tuple[str, str]] = []
while True: while True:
utterance = get_utterance(args.voice, args.duration) utterance = get_utterance(args.voice, args.duration)
if utterance is None: if utterance is None:
@ -264,7 +288,8 @@ def main(argv: list[str] | None = None) -> int:
if utterance.lower() in ("quit", "exit", "stop", "done"): if utterance.lower() in ("quit", "exit", "stop", "done"):
return 0 return 0
try: try:
handle(utterance, schemas, voice=args.voice, verbose=not args.quiet) handle(utterance, schemas, voice=args.voice, verbose=not args.quiet,
history=history)
except Exception as exc: # never let one bad command kill the session except Exception as exc: # never let one bad command kill the session
print(f"WoodShop: sorry, that command failed ({exc}).") print(f"WoodShop: sorry, that command failed ({exc}).")

View File

@ -80,6 +80,7 @@ class Controller(QObject):
self.scene_path = Path(scene_path) if scene_path else default_scene_path() self.scene_path = Path(scene_path) if scene_path else default_scene_path()
self.scene = Scene.load(self.scene_path) self.scene = Scene.load(self.scene_path)
self._schemas: str | None = None self._schemas: str | None = None
self._history: list[tuple[str, str]] = [] # recent (utterance, reply) turns
self.selected: list[str] = [self.scene.selection] if self.scene.selection else [] self.selected: list[str] = [self.scene.selection] if self.scene.selection else []
self.active_feature: str | None = None # feature currently being edited self.active_feature: str | None = None # feature currently being edited
self.preview = None # (Part, Feature) shown as an overlay, or None self.preview = None # (Part, Feature) shown as an overlay, or None
@ -425,7 +426,11 @@ class Controller(QObject):
scene_text = (cli.cmd_status(self.scene, None) scene_text = (cli.cmd_status(self.scene, None)
+ f"\nCurrently selected ('these' / 'them' / 'the selected'): {sel}" + f"\nCurrently selected ('these' / 'them' / 'the selected'): {sel}"
+ "\n" + spatial_summary(self.scene)) + "\n" + spatial_summary(self.scene))
calls = driver.interpret(text, self.schemas(), scene_text=scene_text) calls = driver.interpret(text, self.schemas(), scene_text=scene_text,
history=self._history)
messages = driver.dispatch(calls, verbose=False, executor=self.execute_call) messages = driver.dispatch(calls, verbose=False, executor=self.execute_call)
self._commit() self._commit()
return driver.summarize(calls, messages) spoken = driver.summarize(calls, messages)
self._history.append((text, spoken))
del self._history[:-driver._MAX_HISTORY] # keep a bounded window
return spoken

View File

@ -98,3 +98,40 @@ def test_extract_calls_strips_fences_and_handles_object():
def test_extract_calls_returns_none_on_garbage(): def test_extract_calls_returns_none_on_garbage():
assert driver._extract_calls("no json here") is None assert driver._extract_calls("no json here") is None
def test_render_history_empty_and_populated():
assert driver._render_history(None) == "(no prior turns)"
assert driver._render_history([]) == "(no prior turns)"
text = driver._render_history([("build a table", "Done — placed 9.")])
assert 'User: "build a table"' in text
assert "WoodShop: Done — placed 9." in text
def test_render_history_windowed():
turns = [(f"u{i}", f"a{i}") for i in range(10)]
text = driver._render_history(turns)
assert "u9" in text and "u4" in text # last _MAX_HISTORY kept
assert "u3" not in text # older dropped
def test_interpret_includes_history_in_prompt(monkeypatch):
captured = {}
def fake_run(cmd, stdin=""):
captured["prompt"] = stdin
return "[]"
monkeypatch.setattr(driver, "_run", fake_run)
driver.interpret("yes", schemas="[]", scene_text="empty",
history=[("add tenons?", "Want me to put a tenon on each end?")])
assert "Want me to put a tenon on each end?" in captured["prompt"]
assert 'User: "add tenons?"' in captured["prompt"]
def test_handle_appends_to_history(monkeypatch):
monkeypatch.setattr(driver, "_run",
lambda cmd, stdin="": '[{"tool": "say", "args": {"text": "hi there"}}]')
history = []
driver.handle("hello", schemas="[]", voice=False, verbose=False, history=history)
assert history == [("hello", "hi there")]

View File

@ -138,3 +138,21 @@ def test_break_feature_connection(tmp_path):
def test_unknown_tool_is_safe(tmp_path): def test_unknown_tool_is_safe(tmp_path):
c = _controller(tmp_path) c = _controller(tmp_path)
assert "unknown" in c.execute_call("wood-bogus", {}).lower() assert "unknown" in c.execute_call("wood-bogus", {}).lower()
def test_run_command_threads_history(tmp_path, monkeypatch):
"""run_command feeds prior turns to interpret and records the new turn."""
c = _controller(tmp_path)
seen = {}
def fake_interpret(text, schemas, scene_text=None, history=None):
seen["history"] = list(history or [])
return [{"tool": "say", "args": {"text": "want me to add tenons?"}}]
monkeypatch.setattr(driver, "interpret", fake_interpret)
c.run_command("build a table")
assert seen["history"] == [] # first turn: nothing prior
assert c._history == [("build a table", "want me to add tenons?")]
c.run_command("yes")
assert seen["history"] == [("build a table", "want me to add tenons?")]