personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

chat: consolidate terminal talent handling

+217 -77
+72 -61
convey/chat.py
··· 387 387 next_info = _clear_current_locked() 388 388 389 389 elif use_id in _active_talents: 390 - _cancel_watchdog_locked(use_id) 391 - talent_state = _active_talents.pop(use_id) 392 - logical_use_id = str(talent_state["chat_use_id"]) 393 390 summary = str(message.get("result") or "").strip() 394 - append_chat_event( 391 + next_info = _handle_talent_terminal_locked( 392 + use_id, 395 393 "talent_finished", 396 - use_id=use_id, 397 - name=str(talent_state["target"]), 398 - summary=summary, 394 + "summary", 395 + summary, 399 396 ) 400 - if ( 401 - _current_chat_use_id == logical_use_id 402 - and _current_chat_state is not None 403 - ): 404 - _current_chat_state["trigger"] = { 405 - "type": "talent_finished", 406 - "use_id": use_id, 407 - "name": str(talent_state["target"]), 408 - "summary": summary, 409 - } 410 - _set_current_raw_use_locked( 411 - logical_use_id, 412 - _reserve_use_id_locked(), 413 - ) 414 - _current_chat_state["retry_count"] = 0 415 - next_info = _build_spawn_info_locked(logical_use_id) 416 397 elif _is_superseded_raw_use_id_locked(use_id): 417 398 logger.debug( 418 399 "superseded raw cortex event use_id=%s event=%s reason=%s", ··· 457 438 error_payload = {"use_id": logical_use_id, "reason": CHAT_TROUBLE_REASON} 458 439 next_info = _clear_current_locked() 459 440 elif use_id in _active_talents: 460 - _cancel_watchdog_locked(use_id) 461 - talent_state = _active_talents.pop(use_id) 462 - logical_use_id = str(talent_state["chat_use_id"]) 463 441 reason = str(message.get("error") or CHAT_TROUBLE_REASON) 464 - append_chat_event( 442 + next_info = _handle_talent_terminal_locked( 443 + use_id, 465 444 "talent_errored", 466 - use_id=use_id, 467 - name=str(talent_state["target"]), 468 - reason=reason, 445 + "reason", 446 + reason, 469 447 ) 470 - if ( 471 - _current_chat_use_id == logical_use_id 472 - and _current_chat_state is not None 473 - ): 474 - _current_chat_state["trigger"] = { 475 - "type": "talent_errored", 476 - "use_id": use_id, 477 - "name": str(talent_state["target"]), 478 - "reason": reason, 479 - } 480 - _set_current_raw_use_locked( 481 - logical_use_id, 482 - _reserve_use_id_locked(), 483 - ) 484 - _current_chat_state["retry_count"] = 0 485 - next_info = _build_spawn_info_locked(logical_use_id) 486 448 elif _is_superseded_raw_use_id_locked(use_id): 487 449 logger.debug( 488 450 "superseded raw cortex event use_id=%s event=%s reason=%s", ··· 501 463 _run_next_action(next_info) 502 464 if error_payload is not None: 503 465 _emit_error(error_payload["use_id"], error_payload["reason"]) 466 + 467 + 468 + def _handle_talent_terminal_locked( 469 + use_id: str, 470 + kind: str, 471 + result_field_name: str, 472 + result_value: str, 473 + ) -> dict[str, Any] | None: 474 + _cancel_watchdog_locked(use_id) 475 + talent_state = _active_talents.pop(use_id) 476 + logical_use_id = str(talent_state["chat_use_id"]) 477 + talent_name = str(talent_state["target"]) 478 + trigger = _talent_terminal_trigger( 479 + kind, 480 + use_id, 481 + talent_name, 482 + result_field_name, 483 + result_value, 484 + ) 485 + append_chat_event( 486 + kind, 487 + use_id=use_id, 488 + name=talent_name, 489 + **{result_field_name: result_value}, 490 + ) 491 + if _current_chat_use_id != logical_use_id or _current_chat_state is None: 492 + return None 493 + 494 + _current_chat_state["trigger"] = trigger 495 + _set_current_raw_use_locked( 496 + logical_use_id, 497 + _reserve_use_id_locked(), 498 + ) 499 + _current_chat_state["retry_count"] = 0 500 + return _build_spawn_info_locked(logical_use_id) 504 501 505 502 506 503 def _run_next_action(action: dict[str, Any] | None) -> None: ··· 919 916 if not isinstance(talent_request, dict): 920 917 raise ValueError("chat talent_request must be an object or null") 921 918 target = talent_request.get("target") 922 - if target is None: 923 - # Why: keep one release of compatibility for older chat outputs. 924 - target = "exec" 925 919 if not isinstance(target, str): 926 920 raise ValueError("chat talent_request.target must be a string") 927 921 if target not in {"exec", "reflection"}: ··· 1030 1024 if kind == "owner_message": 1031 1025 return {"type": "owner_message", "message": event.get("text", "")} 1032 1026 if kind == "talent_finished": 1033 - return { 1034 - "type": "talent_finished", 1035 - "use_id": event.get("use_id"), 1036 - "name": event.get("name", "exec"), 1037 - "summary": event.get("summary", ""), 1038 - } 1027 + return _talent_terminal_trigger( 1028 + "talent_finished", 1029 + event.get("use_id"), 1030 + event.get("name", "exec"), 1031 + "summary", 1032 + event.get("summary", ""), 1033 + ) 1039 1034 if kind == "talent_errored": 1040 - return { 1041 - "type": "talent_errored", 1042 - "use_id": event.get("use_id"), 1043 - "name": event.get("name", "exec"), 1044 - "reason": event.get("reason", ""), 1045 - } 1035 + return _talent_terminal_trigger( 1036 + "talent_errored", 1037 + event.get("use_id"), 1038 + event.get("name", "exec"), 1039 + "reason", 1040 + event.get("reason", ""), 1041 + ) 1046 1042 raise ValueError(f"unsupported trigger event: {kind}") 1043 + 1044 + 1045 + def _talent_terminal_trigger( 1046 + kind: str, 1047 + use_id: Any, 1048 + name: Any, 1049 + result_field_name: str, 1050 + result_value: Any, 1051 + ) -> dict[str, Any]: 1052 + return { 1053 + "type": kind, 1054 + "use_id": use_id, 1055 + "name": name, 1056 + result_field_name: result_value, 1057 + } 1047 1058 1048 1059 1049 1060 def _read_result_state(use_id: str) -> dict[str, Any] | None:
+145 -16
tests/test_chat_runtime.py
··· 3 3 4 4 from __future__ import annotations 5 5 6 + import json 6 7 from datetime import datetime 7 8 8 9 import pytest ··· 145 146 "use_id": "1713620000101", 146 147 "result": ( 147 148 '{"message":"I am looking into that.","notes":"need exec",' 148 - '"talent_request":{"task":"research it","context":{"k":"v"}}}' 149 + '"talent_request":{"target":"exec","task":"research it",' 150 + '"context":{"k":"v"}}}' 149 151 ), 150 152 } 151 153 ) ··· 222 224 "use_id": "1713622000000", 223 225 "result": ( 224 226 '{"message":"Still digging.","notes":"loop",' 225 - '"talent_request":{"task":"one more pass","context":{}}}' 227 + '"talent_request":{"target":"exec","task":"one more pass",' 228 + '"context":{}}}' 226 229 ), 227 230 } 228 231 ) ··· 423 426 assert errored_events[-1]["use_id"] == "1713624000001" 424 427 assert actions[-1]["trigger"]["type"] == "talent_errored" 425 428 assert actions[-1]["trigger"]["reason"] == "boom" 429 + 430 + 431 + @pytest.mark.parametrize( 432 + ("terminal_kind", "result_field_name", "result_field_label", "result_value"), 433 + [ 434 + ("talent_finished", "summary", "result", "Found the answer."), 435 + ("talent_errored", "reason", "reason", "The lookup failed."), 436 + ], 437 + ) 438 + def test_terminal_talent_reports_back_without_redispatch( 439 + tmp_path, 440 + monkeypatch, 441 + terminal_kind, 442 + result_field_name, 443 + result_field_label, 444 + result_value, 445 + ): 446 + import convey.chat as chat 447 + from talent import chat_context 448 + 449 + _setup_journal(tmp_path, monkeypatch) 450 + _reset_chat_state(chat) 451 + _install_fake_timers(monkeypatch) 452 + 453 + monkeypatch.setattr("convey.chat._emit_cortex_event", lambda *args, **kwargs: None) 454 + monkeypatch.setattr("think.routines.get_routine_state", lambda: []) 455 + monkeypatch.setattr( 456 + "think.routines.get_config", 457 + lambda: {"_meta": {"suggestions_enabled": False, "suggestions": {}}}, 458 + ) 459 + monkeypatch.setattr("think.routines.save_config", lambda config: None) 460 + 461 + spawns: list[dict] = [] 462 + 463 + def fake_spawn_agent(prompt, name, provider, config, use_id): 464 + spawns.append( 465 + { 466 + "prompt": prompt, 467 + "name": name, 468 + "provider": provider, 469 + "config": dict(config), 470 + "use_id": str(use_id), 471 + } 472 + ) 473 + return use_id 474 + 475 + monkeypatch.setattr("convey.utils.spawn_agent", fake_spawn_agent) 476 + 477 + app = Flask(__name__) 478 + app.register_blueprint(chat.chat_bp) 479 + app.testing = True 480 + client = app.test_client() 481 + 482 + response = client.post( 483 + "/api/chat", 484 + json={ 485 + "message": "Can you check this?", 486 + "app": "sol", 487 + "path": "/app/sol", 488 + "facet": "work", 489 + }, 490 + ) 491 + assert response.status_code == 200 492 + assert response.get_json()["queued"] is False 493 + 494 + first_chat_spawn = spawns[-1] 495 + assert first_chat_spawn["name"] == "chat" 496 + chat._on_cortex_finish( 497 + { 498 + "use_id": first_chat_spawn["use_id"], 499 + "result": json.dumps( 500 + { 501 + "message": "I am checking.", 502 + "notes": "dispatch exec", 503 + "talent_request": { 504 + "target": "exec", 505 + "task": "Check the thing", 506 + "context": {"facet": "work"}, 507 + }, 508 + } 509 + ), 510 + } 511 + ) 512 + 513 + talent_spawn = spawns[-1] 514 + assert talent_spawn["name"] == "exec" 515 + if terminal_kind == "talent_finished": 516 + chat._on_cortex_finish( 517 + {"use_id": talent_spawn["use_id"], "result": result_value} 518 + ) 519 + else: 520 + chat._on_cortex_error({"use_id": talent_spawn["use_id"], "error": result_value}) 521 + 522 + report_back_spawn = spawns[-1] 523 + assert report_back_spawn["name"] == "chat" 524 + assert report_back_spawn["config"]["trigger"]["type"] == terminal_kind 525 + assert report_back_spawn["config"]["trigger"][result_field_name] == result_value 526 + 527 + context_result = chat_context.pre_process(report_back_spawn["config"]) 528 + followup = context_result["messages"][-1]["content"] 529 + trigger_context = context_result["template_vars"]["trigger_context"] 530 + stop_and_report = ( 531 + "stop-and-report turn, not a dispatch turn. Do not retry this task " 532 + "or request another talent for it. Stop here and report to the owner " 533 + "directly using the" 534 + ) 535 + assert stop_and_report in followup 536 + assert stop_and_report in trigger_context 537 + assert f"{result_field_label.capitalize()}: {result_value}" in followup 538 + 539 + raw_report_use_id = report_back_spawn["use_id"] 540 + chat._on_cortex_finish( 541 + { 542 + "use_id": raw_report_use_id, 543 + "result": json.dumps( 544 + { 545 + "message": "Here is the summary.", 546 + "notes": "reported terminal talent result", 547 + "talent_request": None, 548 + } 549 + ), 550 + } 551 + ) 552 + 553 + events = read_chat_events(chat._today_day()) 554 + sol_messages = [event for event in events if event["kind"] == "sol_message"] 555 + assert sol_messages[-1]["text"] == "Here is the summary." 556 + assert sol_messages[-1]["requested_target"] is None 557 + talent_spawns = [event for event in events if event["kind"] == "talent_spawned"] 558 + assert len(talent_spawns) == 1 559 + assert [spawn["name"] for spawn in spawns] == ["chat", "exec", "chat"] 426 560 427 561 428 562 def test_start_chat_runtime_recovers_exactly_one_unresponded_trigger( ··· 1259 1393 ) 1260 1394 1261 1395 1262 - def test_parse_chat_result_defaults_legacy_target_to_exec(): 1396 + def test_parse_chat_result_rejects_missing_target(): 1263 1397 import convey.chat as chat 1264 1398 1265 - parsed = chat._parse_chat_result( 1266 - { 1267 - "message": "I am looking into that.", 1268 - "notes": "need exec", 1269 - "talent_request": {"task": "Research it", "context": {"k": "v"}}, 1270 - } 1271 - ) 1272 - 1273 - assert parsed["talent_request"] == { 1274 - "target": "exec", 1275 - "task": "Research it", 1276 - "context": {"k": "v"}, 1277 - } 1399 + with pytest.raises(ValueError, match="chat talent_request.target must be a string"): 1400 + chat._parse_chat_result( 1401 + { 1402 + "message": "I am looking into that.", 1403 + "notes": "need exec", 1404 + "talent_request": {"task": "Research it", "context": {"k": "v"}}, 1405 + } 1406 + ) 1278 1407 1279 1408 1280 1409 def test_reflection_dispatch_spawns_reflection_talent(tmp_path, monkeypatch):