diff --git a/solstone/apps/support/talent/support.md b/solstone/apps/support/talent/support.md index 857b70fb2..663e4a987 100644 --- a/solstone/apps/support/talent/support.md +++ b/solstone/apps/support/talent/support.md @@ -2,23 +2,23 @@ "type": "cogitate", "access_tier": "outbound", "title": "Support", - "description": "Drafts support requests and feedback to sol pbc for owner review, searches help articles, and runs local diagnostics.", + "description": "Drafts support requests and feedback to solstone support for owner review, searches help articles, and runs local diagnostics.", "color": "#0288d1" } -You are $agent_name's support agent. You help $name prepare support requests and feedback for sol pbc, search help articles, check existing tickets, and run local diagnostics. You are $preferred's advocate: you work for the owner, not sol pbc. +You are $agent_name's support agent. You help $name prepare support requests and feedback for solstone support, search help articles, check existing tickets, and run local diagnostics. You are $preferred's advocate: you work for the owner, not solstone support. -When support is needed, frame the work plainly: "I'll prepare the request and put it in front of you to review; you decide whether it goes to solstone support." +When support is needed, frame the work plainly: "I'll prepare a local draft for you to review; nothing leaves this machine or goes to solstone support unless you choose that from the review card." ## Critical Privacy Rules These are non-negotiable: -1. **Draft only.** Prepare exactly one structured draft when support should be contacted, show it to the owner, and finish. Do not run a submit path. +1. **Draft only.** Prepare exactly one structured local draft when support should be contacted, show it to the owner, and finish. Do not run a submit path. 2. **NEVER include journal content by default.** Attach a transcript, screenshot, or any journal-derived content only if the owner explicitly says so. 3. **Always show the owner every field and every diagnostic value** in prose. The review card is the source of truth, but your reply must still show the full draft clearly. -4. **If support is disabled in settings, only help locally** with diagnostics, help articles, announcements, and troubleshooting. No outbound communication. -5. **Never imply something was filed or sent.** A prepared draft is not a ticket, reply, or feedback submission. +4. **If support is disabled in settings, only help locally** with diagnostics, help articles, announcements, and troubleshooting. This is a settings state, not a failed support attempt. +5. **Never imply something was filed or sent.** A prepared local draft is not a ticket, reply, or feedback submission. ## Available Reads @@ -57,9 +57,9 @@ The `reply` and `attach` commands need `--no-submit` to prepare a draft. Do not After the dry-run command: -1. If the output shows `Draft not captured` or `(Draft not captured — solstone wasn't reachable to save it for review.)`, tell the owner plainly that the draft could not be prepared and to try again. Do not imply a review card is coming. -2. Otherwise, show the owner the full draft in prose: subject, description or body, severity, category, ticket id for replies, and every diagnostic value included. -3. Tell the owner that they review and decide from the review card whether it goes to solstone support. +1. If the output shows `Draft not captured` or `(Draft not captured — solstone wasn't reachable to save it for review.)`, treat it as a draft-preparation failure: tell the owner plainly that the local review draft could not be prepared and to try again. Do not imply a review card is coming, and do not frame it as an attempted send to support. +2. Otherwise, the local draft was prepared successfully. Show the owner the full draft in prose: subject, description or body, severity, category, ticket id for replies, and every diagnostic value included. +3. Tell the owner that they review the local draft and decide from the review card whether it goes to solstone support. 4. Finish immediately with the built-in `FinishTool`. For visual bugs, a screenshot can help support understand what the owner sees. Prepare an attachment draft with `--no-submit` only when the owner explicitly provides or asks to attach a file. Never attach journal content — transcript, screenshot, or journal-derived content — unless the owner explicitly asks. @@ -67,16 +67,16 @@ For visual bugs, a screenshot can help support understand what the owner sees. P ## Tone - Be helpful, direct, and owner-centered. -- Work for the owner, not sol pbc. +- Work for the owner, not solstone support. - Be precise about what is prepared locally versus what has left the machine. - Prefer local resolution through help articles, announcements, and diagnostics when that answers the need. ## When NOT to Draft - If the owner is asking how to use a feature, answer from help articles or redirect to the full assistant. -- If support is disabled in settings, explain that support communication is off and offer local-only help. +- If support is disabled in settings, explain that support submission is off by setting, not failed, and offer local-only help. - If the owner has not asked to contact support and the issue can be solved locally, solve it locally. ## Finalize -This is an interactive talent: produce your reply to the owner, prepare at most one draft, then conclude with the built-in finish tool (`FinishTool`). This talent has no `emit_final`. Never report a submit as complete; this talent does not submit. +This is an interactive talent: produce your reply to the owner, prepare at most one local draft, then conclude with the built-in finish tool (`FinishTool`). This talent has no `emit_final`. Never report a submit as complete; this talent does not submit. diff --git a/solstone/apps/support/talent/support/SKILL.md b/solstone/apps/support/talent/support/SKILL.md index 0da3e930a..9b57e10e9 100644 --- a/solstone/apps/support/talent/support/SKILL.md +++ b/solstone/apps/support/talent/support/SKILL.md @@ -1,7 +1,7 @@ --- name: support description: > - Draft support tickets and feedback to sol pbc, search the KB, check open + Draft support tickets and feedback to solstone support, search the KB, check open tickets, check announcements, and run diagnostics. TRIGGER: file bug, request feature, submit feedback, search KB, announcements, tickets, sol call support create/search/list/reply/diagnose. @@ -19,7 +19,7 @@ Draft tickets, search the knowledge base, prepare feedback, and check existing s 3. **Diagnostics are auto-populated.** When creating a ticket, `sol call support create` includes system info (version, OS, services, recent errors). You do not need to gather this manually. -4. **Draft only.** `create` and `feedback` run as dry-runs that save a draft for owner review. `reply` and `attach` need `--no-submit` to draft. Nothing is sent by the agent. +4. **Draft only.** `create` and `feedback` run as dry-runs that save a local draft for owner review. `reply` and `attach` need `--no-submit` to draft. The draft stays local until the owner chooses from the review card. ## Subcommands @@ -53,14 +53,14 @@ sol call support create \ --category bug ``` -The `create` command is a dry run by default: it prints the would-be payload, saves a draft for owner review, and sends nothing. +The `create` command is a dry run by default: it prints the would-be payload and saves a local draft for owner review. The `create` command implements a KB-first flow: 1. Searches KB for related articles 2. Shows matches 3. Includes diagnostics automatically 4. Shows the full ticket draft for review -5. Saves a draft for the owner to review +5. Saves a local draft for the owner to review **Flags:** - `--subject` / `-s` - Ticket subject (required) @@ -97,7 +97,7 @@ sol call support show 42 --json sol call support attach 42 screenshot.png --no-submit ``` -Screenshots can help support understand visual bugs. Prepare an attachment draft only when the owner explicitly provides or asks to attach a file, and always include `--no-submit`. Never attach journal content — transcript, screenshot, or journal-derived content — unless the owner explicitly asks. Nothing is sent by the agent. +Screenshots can help support understand visual bugs. Prepare an attachment draft only when the owner explicitly provides or asks to attach a file, and always include `--no-submit`. Never attach journal content — transcript, screenshot, or journal-derived content — unless the owner explicitly asks. The attachment draft stays local until the owner chooses from the review card. ### Feedback @@ -105,7 +105,7 @@ Screenshots can help support understand visual bugs. Prepare an attachment draft sol call support feedback --body "The entity search is great but I wish it could filter by date range" ``` -Lower friction than a full ticket. Feedback is dry run by default: it prints the would-be payload, saves a draft for owner review, and sends nothing. Feedback is shaped as a ticket with category "feedback". Supports `--anonymous`. +Lower friction than a full ticket. Feedback is dry run by default: it prints the would-be payload and saves a local draft for owner review. Feedback is shaped as a ticket with category "feedback". Supports `--anonymous`. ### Announcements @@ -122,7 +122,7 @@ sol call support diagnose sol call support diagnose --json ``` -Reflects the journal host (read-only; no support ticket is sent). Shows: +Reflects the journal host (read-only; does not create a support ticket). Shows: - solstone version - OS/platform info - Active services and their status @@ -167,12 +167,12 @@ sol call support show 15 sol call support diagnose ``` -Running `create` or `feedback` produces a safe dry-run preview and saves a draft for owner review. +Running `create` or `feedback` produces a safe dry-run preview and saves a local draft for owner review. ## Gotchas -- **`create`/`feedback` are dry-run by default.** They save a draft for owner review. The `DRY RUN` banner in stdout is the signal that nothing was sent. +- **`create`/`feedback` are dry-run by default.** They save a local draft for owner review. The `DRY RUN` banner in stdout is the signal that the draft remains local. - **`reply` and `attach` need `--no-submit` for drafts.** Always include it when preparing a reply or attachment for owner review. - **KB-first is automatic on `create`.** The `create` command always searches the KB and shows matches for owner review before preparing the draft. Pass `--skip-kb` only if the issue is clearly unique. -- **`--product` defaults to solstone.** Support handles other sol pbc products too. Confirm with the owner before preparing a non-solstone ticket. +- **`--product` defaults to solstone.** Solstone support handles other products too. Confirm with the owner before preparing a non-solstone ticket. - **Diagnostics can include configuration.** Secrets are stripped, but the full diagnostic payload must still be shown to the owner. diff --git a/solstone/convey/chat.py b/solstone/convey/chat.py index 72e34d942..41fdc7e52 100644 --- a/solstone/convey/chat.py +++ b/solstone/convey/chat.py @@ -81,6 +81,7 @@ MAX_ACTIVE_TALENTS = 2 _WATCHDOG_TIMEOUTS = {"chat": 30, "talent": 180} _DEFAULT_WATCHDOG_SECONDS = 180 _RESERVED_USE_ID_CAP = 256 +_ABANDONED_RAW_USE_ID_CAP = 256 _state_lock = threading.Lock() _runtime_lock = threading.Lock() @@ -89,6 +90,7 @@ _current_chat_state: dict[str, Any] | None = None _queued_triggers: deque[dict[str, Any]] = deque() _active_talents: dict[str, dict[str, Any]] = {} _reserved_use_ids: dict[str, None] = {} +_abandoned_raw_use_ids: dict[str, None] = {} _thinking_buffers: dict[str, list[str]] = {} _thinking_providers: dict[str, str] = {} _watchdog_timers: dict[str, threading.Timer] = {} @@ -467,6 +469,7 @@ def stop_all_chat_runtime() -> None: timer.cancel() _watchdog_timers.clear() _reserved_use_ids.clear() + _abandoned_raw_use_ids.clear() _thinking_buffers.clear() _thinking_providers.clear() @@ -1432,9 +1435,20 @@ def _pop_next_trigger_locked() -> dict[str, Any] | None: return _queued_triggers.popleft() +def _abandon_raw_use_ids_locked(use_ids: set[str] | None) -> None: + if not use_ids: + return + for use_id in sorted(use_ids): + _abandoned_raw_use_ids[use_id] = None + while len(_abandoned_raw_use_ids) > _ABANDONED_RAW_USE_ID_CAP: + _abandoned_raw_use_ids.pop(next(iter(_abandoned_raw_use_ids))) + + def _clear_current_locked() -> dict[str, Any] | None: global _current_chat_use_id, _current_chat_state + if _current_chat_state is not None: + _abandon_raw_use_ids_locked(_current_chat_state.get("raw_use_ids_seen")) _current_chat_use_id = None _current_chat_state = None queued = _pop_next_trigger_locked() @@ -1489,12 +1503,13 @@ def _set_current_raw_use_locked(logical_use_id: str, raw_use_id: str | None) -> def _is_superseded_raw_use_id_locked(use_id: str) -> bool: - if _current_chat_state is None: - return False - raw_chat_use_id = str(_current_chat_state.get("raw_use_id") or "") - if use_id == raw_chat_use_id: - return False - return use_id in _current_chat_state["raw_use_ids_seen"] + if _current_chat_state is not None: + raw_chat_use_id = str(_current_chat_state.get("raw_use_id") or "") + if use_id == raw_chat_use_id: + return False + if use_id in _current_chat_state["raw_use_ids_seen"]: + return True + return use_id in _abandoned_raw_use_ids def _capture_thinking_locked(message: dict[str, Any]) -> None: diff --git a/solstone/convey/chat_stream.py b/solstone/convey/chat_stream.py index 73afc07dc..20667fa9a 100644 --- a/solstone/convey/chat_stream.py +++ b/solstone/convey/chat_stream.py @@ -354,11 +354,19 @@ def reduce_chat_state(day: str) -> dict[str, Any]: def find_unresponded_trigger(day: str) -> dict[str, Any] | None: - """Return the most recent unresolved trigger event for ``day``.""" + """Return the most recent unresolved trigger event for ``day``. + + A turn terminally closed by ``chat_error`` is not unresponded: timeout, + provider-error, and invalid-response paths all clear the active turn. An + ``owner_message`` with no later terminal event stays restartable for crash + recovery. + """ for event in reversed(read_chat_events(day)): kind = event.get("kind") if kind == "sol_message": return None + if kind == "chat_error": + return None if kind in _TRIGGER_KINDS: return event return None diff --git a/solstone/talent/chat_context.py b/solstone/talent/chat_context.py index f6cf1b6e4..5e5ee6923 100644 --- a/solstone/talent/chat_context.py +++ b/solstone/talent/chat_context.py @@ -21,6 +21,10 @@ STOP_AND_REPORT_CONTRACT = ( "another talent for it. Stop here and report to the owner directly using the " "{result_field_label} below." ) +CHAT_ERROR_TURN_MARKER = ( + "[internal: my previous reply to the message above didn't complete. Treat it " + "as background -- the owner's latest message below is the active request.]" +) def pre_process(context: dict) -> dict: @@ -53,6 +57,14 @@ def pre_process(context: dict) -> dict: messages.append({"role": "user", "content": event["text"]}) elif event["kind"] == "sol_message": messages.append({"role": "assistant", "content": event["text"]}) + elif ( + event["kind"] == "chat_error" + and messages + and messages[-1].get("role") == "user" + ): + messages.append( + {"role": "assistant", "content": CHAT_ERROR_TURN_MARKER} + ) if latest_owner_message and "source" in latest_owner_message: source = latest_owner_message["source"] diff --git a/tests/test_chat_context.py b/tests/test_chat_context.py index d7a7f95ce..a26c1dbcd 100644 --- a/tests/test_chat_context.py +++ b/tests/test_chat_context.py @@ -302,6 +302,40 @@ def test_chat_context_owner_message_anchors_after_different_user(monkeypatch, tm ] +def test_chat_context_chat_error_marks_incomplete_prior_user_turn( + monkeypatch, tmp_path +): + journal = tmp_path / "journal" + monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) + + _append_owner_message("A", _ts(8, 0)) + append_chat_event( + "chat_error", + ts=_ts(8, 1), + reason="chat_timeout", + use_id="use-chat-timeout", + ) + _append_owner_message("B", _ts(8, 2)) + + module = _load_chat_context_module() + result = module.pre_process( + { + "day": "20260420", + "trigger": { + "type": "owner_message", + "message": "B", + "ts": _ts(8, 2), + }, + } + ) + + assert result["messages"] == [ + {"role": "user", "content": "A"}, + {"role": "assistant", "content": module.CHAT_ERROR_TURN_MARKER}, + {"role": "user", "content": "B"}, + ] + + def test_chat_context_prompt_only_owner_message_anchors(monkeypatch, tmp_path): journal = tmp_path / "journal" monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal)) diff --git a/tests/test_chat_runtime.py b/tests/test_chat_runtime.py index 8ac72f6ec..67967e519 100644 --- a/tests/test_chat_runtime.py +++ b/tests/test_chat_runtime.py @@ -26,6 +26,7 @@ def _reset_chat_state(chat_module) -> None: chat_module._queued_triggers.clear() chat_module._active_talents.clear() chat_module._reserved_use_ids.clear() + chat_module._abandoned_raw_use_ids.clear() for timer in chat_module._watchdog_timers.values(): timer.cancel() chat_module._watchdog_timers.clear() @@ -1760,6 +1761,84 @@ def test_chat_watchdog_times_out_current_chat_generate(tmp_path, monkeypatch): assert raw_use_id not in chat._watchdog_timers +def test_late_chat_finish_after_watchdog_timeout_is_abandoned_raw( + tmp_path, monkeypatch, caplog +): + import solstone.convey.chat as chat + + _setup_journal(tmp_path, monkeypatch) + _reset_chat_state(chat) + timers = _install_fake_timers(monkeypatch) + + emitted_errors: list[tuple[str, str, dict]] = [] + emitted_finishes: list[tuple[str, str]] = [] + run_actions: list[dict | None] = [] + monkeypatch.setattr( + "solstone.convey.chat._emit_error", + lambda use_id, reason, **kwargs: emitted_errors.append( + (use_id, reason, kwargs) + ), + ) + monkeypatch.setattr( + "solstone.convey.chat._emit_finish", + lambda use_id, message: emitted_finishes.append((use_id, message)), + ) + monkeypatch.setattr( + "solstone.convey.chat._run_next_action", + lambda action: run_actions.append(action), + ) + + logical_use_id = "1713628050000" + with chat._state_lock: + start_info = chat._activate_current_locked( + logical_use_id, + {"type": "owner_message", "message": "first"}, + {"app": "sol", "path": "/app/sol", "facet": "work"}, + ) + queued_use_id, queued, start_action = chat._activate_or_enqueue_trigger_locked( + {"type": "owner_message", "message": "second"}, + {"app": "sol", "path": "/app/sol", "facet": "work"}, + ) + + assert queued is True + assert start_action is None + raw_use_id = start_info["raw_use_id"] + + timers[-1].fire() + + assert emitted_errors == [(logical_use_id, "chat_timeout", {})] + assert run_actions and run_actions[-1]["logical_use_id"] == queued_use_id + with chat._state_lock: + assert chat._current_chat_use_id == queued_use_id + assert chat._current_chat_state is not None + assert chat._current_chat_state["raw_use_id"] != raw_use_id + + events_before_late = read_chat_events(chat._today_day()) + caplog.clear() + caplog.set_level(logging.DEBUG, logger="solstone.convey.chat") + chat._on_cortex_finish( + { + "use_id": raw_use_id, + "result": json.dumps( + {"message": "late answer", "notes": "ok", "talent_request": None} + ), + } + ) + + assert read_chat_events(chat._today_day()) == events_before_late + assert emitted_finishes == [] + assert ( + f"superseded raw cortex event use_id={raw_use_id} " + "event=finish reason=raw rotated" + ) in caplog.text + warning_messages = [ + record.getMessage() + for record in caplog.records + if record.name == "solstone.convey.chat" and record.levelno >= logging.WARNING + ] + assert all("unrouteable cortex event" not in msg for msg in warning_messages) + + def test_chat_watchdog_times_out_active_talent_and_queues_fold_when_chat_busy( tmp_path, monkeypatch ): diff --git a/tests/test_chat_stream.py b/tests/test_chat_stream.py index 508d3d965..4daac280f 100644 --- a/tests/test_chat_stream.py +++ b/tests/test_chat_stream.py @@ -1113,6 +1113,45 @@ def test_talent_queued_is_not_an_unresponded_trigger(tmp_path, monkeypatch): assert find_unresponded_trigger("20260420") is None +def test_find_unresponded_trigger_chat_error_terminal_preserves_owner_recovery( + tmp_path, monkeypatch +): + _setup_journal(tmp_path, monkeypatch) + start = _ms(2026, 4, 20, 12, 0, 0) + + append_chat_event( + "owner_message", + ts=start, + text="hello", + app="sol", + path="/chat", + facet="work", + ) + append_chat_event( + "chat_error", + ts=start + 1_000, + reason="chat_timeout", + use_id="chat-1", + ) + + assert find_unresponded_trigger("20260420") is None + + recovery_start = _ms(2026, 4, 21, 12, 0, 0) + append_chat_event( + "owner_message", + ts=recovery_start, + text="restart me", + app="sol", + path="/chat", + facet="work", + ) + + trigger = find_unresponded_trigger("20260421") + assert trigger is not None + assert trigger["kind"] == "owner_message" + assert trigger["text"] == "restart me" + + def test_find_unresponded_trigger_resolved(tmp_path, monkeypatch): _setup_journal(tmp_path, monkeypatch) start = _ms(2026, 4, 20, 12, 0, 0) diff --git a/tests/test_support_talent_prompt.py b/tests/test_support_talent_prompt.py index 52ab55448..f29215e22 100644 --- a/tests/test_support_talent_prompt.py +++ b/tests/test_support_talent_prompt.py @@ -23,3 +23,29 @@ def test_support_talent_prompt_is_draft_only() -> None: assert "per-send owner approval" not in lower_text assert "gate denial" not in lower_text assert "--submit" not in skill_text + + +def test_support_talent_prompt_distinguishes_draft_states() -> None: + prompt_path = REPO_ROOT / "solstone/apps/support/talent/support.md" + text = prompt_path.read_text() + lower_text = text.lower() + + assert "local draft was prepared successfully" in lower_text + assert ( + "nothing leaves this machine or goes to solstone support unless you choose" + in lower_text + ) + assert "support submission is off by setting, not failed" in lower_text + assert "draft-preparation failure" in lower_text + assert "local review draft could not be prepared" in lower_text + assert "sol pbc" not in lower_text + + success_start = text.index( + "2. Otherwise, the local draft was prepared successfully." + ) + success_end = text.index("3. Tell the owner", success_start) + success_text = text[success_start:success_end].lower() + + assert "couldn't reach" not in success_text + assert "could not be prepared" not in success_text + assert "nothing was sent" not in success_text