diff --git a/.gitignore b/.gitignore index 563c848f..25892d38 100644 --- a/.gitignore +++ b/.gitignore @@ -253,3 +253,6 @@ data/memory.db* # demo stack supervisor: pids, git sha and source fingerprints of the # processes this checkout started. Machine-local by definition. .demo/ + +# written beside taxonomy.py when scripts/dev/tests runs; machine-local +scripts/dev/tests/per_test.json diff --git a/CALENDAR_QUERY_LOCATIONS.md b/CALENDAR_QUERY_LOCATIONS.md index 56984a1e..5a64098b 100644 --- a/CALENDAR_QUERY_LOCATIONS.md +++ b/CALENDAR_QUERY_LOCATIONS.md @@ -8,8 +8,11 @@ > access it uses instead. `admonisher/base.py` and `admonisher/calendar.py` > have no caller in `src/` or `scripts/`; their tests were removed in the > 2026-09 test-suite prune (`tests/README.md`, "What's out of `tests/unit/`"). -> Everything below is left as written, as a record of what was true then β€” -> it is not a guide to the current architecture. +> `McpCalendarClient` (`fateforger.agents.timeboxing.mcp_clients`) is also +> gone now β€” it left with the legacy timeboxing agent on 2026-09-09; +> `src/tmbx/calendar/` (`port.py`, `gcal.py`, `fake.py`) is the calendar port +> on the surviving path. Everything below is left as written, as a record of +> what was true then β€” it is not a guide to the current architecture. ## 🎯 Summary diff --git a/MIGRATION_ARCHIVE_TO_CALENDAR_HAUNTER.md b/MIGRATION_ARCHIVE_TO_CALENDAR_HAUNTER.md index d292a63d..a24bac12 100644 --- a/MIGRATION_ARCHIVE_TO_CALENDAR_HAUNTER.md +++ b/MIGRATION_ARCHIVE_TO_CALENDAR_HAUNTER.md @@ -7,8 +7,12 @@ > `src/fateforger/haunt/reconcile.py` and `src/fateforger/haunt/service.py` > are where calendar reconciliation and reminder orchestration live now. The > `CalendarHaunter` tests were removed in the 2026-09 test-suite prune -> (`tests/README.md`, "What's out of `tests/unit/`"). Everything below is -> left as written, as a record of what was true then. +> (`tests/README.md`, "What's out of `tests/unit/`"). `McpCalendarClient` +> (`fateforger.agents.timeboxing.mcp_clients`) is also gone now β€” it left +> with the legacy timeboxing agent on 2026-09-09; `src/tmbx/calendar/` +> (`port.py`, `gcal.py`, `fake.py`) is the calendar port on the surviving +> path. Everything below is left as written, as a record of what was true +> then. If you have code using the old `CalendarMcpClient` from the archive, here's how to migrate to the mature `CalendarHaunter` implementation. diff --git a/docs/architecture/agents.md b/docs/architecture/agents.md index 283342d6..31f53fca 100644 --- a/docs/architecture/agents.md +++ b/docs/architecture/agents.md @@ -2,66 +2,55 @@ title: Agents --- -## TimeboxingFlowAgent +> **Retired (2026-09-09).** `TimeboxingFlowAgent` (`agents/timeboxing/agent.py`), +> `ConstraintRetriever` (`agents/timeboxing/constraint_retriever.py`), and the +> Notion-backed `ConstraintExtractorAgent` are deleted with the coordinator +> that owned them β€” `refactor: retire TimeboxingFlowAgent and the 34 modules +> only it reached` (commit `67489cd`). The sections below described those +> three; see `docs/indices/agents_timeboxing.md` for the file-by-file +> retirement note. What follows is the three components that actually plan +> and write a day now. + +## Adaptive timeboxing kernel + +Artifact-led planning-session orchestration: the Stage 1 elicitation loop +(coverage matrix, arithmetic gate, three judges) plus the newer +artifact-led session state (`session_contracts.py`, `readiness.py`, +`required_blocks.py`, `day_frame.py`, `feedback.py`). The kernel decides +what a planning turn does but takes timezone, calendar, and constraint-store +access as ports; it is driven from the Slack host, which supplies those +ports and does the actual Slack routing. -Primary day-planning agent that runs the GraphFlow timeboxing workflow and coordinates: -- Stage-gated planning for day schedule drafts (typed JSON contexts per stage) -- Patch-based refinement (`TimeboxPatcher`) -- Constraint extraction + persistence (background, non-blocking) - -Code: `src/fateforger/agents/timeboxing/agent.py` +Code: +- `src/fateforger/agents/timeboxing/adaptive_timeboxing.py` +- `src/fateforger/slack_bot/timeboxing_host.py` (the host that supplies the kernel's ports and calls it per Stage 1 turn) Related docs: - - `docs/indices/agents_timeboxing.md` -- `docs/architecture/timeboxing_refactor.md` -- `docs/architecture/constraint-flow.md` -- `docs/architecture/proposal_object_contract.md` +- `docs/superpowers/specs/2026-09-05-stage1-elicitation-loop-design.md` -## ConstraintExtractorAgent (Notion-backed) +## Harness planner (DeepSeek) -Extractor agent that turns user preference corrections into a deterministic constraint record and -upserts it into Notion for durable future reuse. - -- Output schema: `ConstraintExtractionOutput` (JSON, structured) -- Persistence: `NotionConstraintStore.upsert_constraint(...)` + `TB Constraint Events` audit log -- Timeboxing agents can call the tool `extract_and_upsert_constraint` (Agent-as-Tool under the hood). -- Notion access is via the constraint-memory MCP server (`scripts/constraint_mcp_server.py`). +Host-owned context boundary for adaptive planning turns: refreshes the +constraint and calendar read models for the locked day and hands one +complete brief to a fresh harness run. Reads durable constraints via +`kg_constraint_client.py`, the read-only client onto the standalone memory +server's own store (`data/memory.db`), speaking the `DurableConstraintStore` +protocol `durable_constraint_store.py` defines. Code: -- `src/fateforger/agents/timeboxing/notion_constraint_extractor.py` -- `src/fateforger/adapters/notion/timeboxing_preferences.py` - -## ConstraintRetriever +- `src/fateforger/slack_bot/deepseek_timebox_planner.py` +- `src/fateforger/agents/timeboxing/kg_constraint_client.py` +- `src/fateforger/agents/timeboxing/durable_constraint_store.py` -Gap-driven retriever for durable constraints that: -- derives a small query plan from stage + day context (gaps/blocks/immovables) -- uses `constraint_query_types` to select relevant `type_id`s -- then queries constraints via `constraint_query_constraints` with those `type_id`s +## tmbx server (calendar writes) -Code: -- `src/fateforger/agents/timeboxing/constraint_retriever.py` -- `src/fateforger/agents/timeboxing/mcp_clients.py` -- `src/fateforger/agents/timeboxing/agent.py` +MCP server exposing the level 1 timebox tools (`plan_read`, `plan_apply`, +`plan_commit`, `plan_undo`, `plan_history`) that read and write the day's +Google Calendar events. A write path can refuse (reported as a normal JSON +result with a `"reason"` code, never raised as an exception) rather than +silently applying a stale or conflicting patch. -## (Next) ConstraintRetriever Improvements +Code: `src/tmbx/server.py` -Planned improvements: -- loads global/profile constraints first (high precedence) -- then queries only what is needed for remaining planning gaps ("degrees of freedom") -- uses structured Notion properties (no embeddings requirement) - -Status: partially implemented; tracked in `lattice_ticket.md`. - -## SlackBot Router + Review - -Slack-facing routing + constraint review UI: -- Extracted constraints can be reviewed and accepted/declined via a Slack modal. -- Current implementation updates the local SQLite-backed constraint statuses. -- Proposal interactions should follow the shared contract in - `docs/architecture/proposal_object_contract.md`: - UI actions and NL replies must converge to the same typed intent + submit path. - -Code: -- `src/fateforger/slack_bot/handlers.py` -- `src/fateforger/slack_bot/constraint_review.py` +Related docs: `src/tmbx/` module docstrings; `tickets/` entries under `tmbx`. diff --git a/docs/architecture/timeboxing_refactor.md b/docs/architecture/timeboxing_refactor.md index abfd0632..bde053e1 100644 --- a/docs/architecture/timeboxing_refactor.md +++ b/docs/architecture/timeboxing_refactor.md @@ -4,6 +4,14 @@ title: Timeboxing Refactor # Timeboxing Refactor +> **Superseded (2026-09-09).** This page describes `TimeboxingFlowAgent`'s +> coordinator + stage-gating design, retired with the agent itself (commit +> `67489cd`); every code pointer below except `mcp_clients.py` names a +> deleted file. Of the tests listed below only +> `tests/e2e/test_slack_timebox_command.py` survives. See +> `docs/architecture/agents.md` for the live components and +> `docs/indices/agents_timeboxing.md` for the retirement note and file index. + This page summarizes the β€œprompt-splitting + typed stage contexts + background constraints” refactor for timeboxing. For the detailed repo-level report, see `TIMEBOXING_REFACTOR_REPORT.md`. diff --git a/docs/superpowers/specs/2026-09-08-test-suite-composability-design.md b/docs/superpowers/specs/2026-09-08-test-suite-composability-design.md index bcff828e..bcdc8adc 100644 --- a/docs/superpowers/specs/2026-09-08-test-suite-composability-design.md +++ b/docs/superpowers/specs/2026-09-08-test-suite-composability-design.md @@ -166,10 +166,9 @@ in the spike: **51 source files, 17,584 lines**, in four groups. `scheduler_prefetch_capability`, `task_marshalling_capability`, `tool_result_presenter`, `toon_views`, `llm/toon`, `shared/handoff_policy`). - **Found by the stores spike, not by reachability:** - - `mcp_clients.py` whole. `ConstraintMemoryClient` is imported by - `tasks/defaults_memory.py` but never constructed (`TASKS_DEFAULTS_MEMORY_BACKEND=disabled` - returns first); `McpCalendarClient`'s only importer is `agent.py`. Cut the - `defaults_memory.py:21` import with it. + - `McpCalendarClient` and `CalendarDaySnapshot` leave `mcp_clients.py`; + `ConstraintMemoryClient` stays β€” it is the default + `tasks_defaults_memory_backend` with tests of its own. - `preferences.ConstraintStore`, `ensure_constraint_schema`, `handlers._update_constraints` / `_maybe_update_timeboxing_thread_constraints`, and the `constraint_review.py` handler set. Their only writers are the @@ -177,10 +176,9 @@ in the spike: **51 source files, 17,584 lines**, in four groups. table is permanently empty and the harness-side reads are no-ops. Keep the `Constraint` / `ConstraintStatus` / `ConstraintScope` types β€” `messages.py` types against them. - - `settings.timeboxing_memory_backend` and its validator (`config.py:223, - 300-309`): read only by `agent.py:1089`. The harness hardcodes - `KGConstraintMemoryClient(settings.memory_db_path)`. Remove the setting - and the `TIMEBOXING_MEMORY_BACKEND` line from `.env.template`. + - `settings.timeboxing_memory_backend`: Kept. It is also read by + `runtime.py`'s graphiti startup checks and by tasks' defaults memory; + only `agent.py`'s branch on it goes. - The already-dead set PR #396 flagged: `admonisher/{base,calendar,commitment}`, `schedular/diffing_agent`, `timeboxing/{flow,prompts,state,notebook_entrypoints}`, `slack_bot/{relay_agent,topics}`, `tools_config/`. **Not deletable as @@ -364,8 +362,11 @@ The rule from #396 stands: a double used by two modules lives here. The draft proposed a `workbench=` keyword on four constructors. The spike implemented it, mutation-tested it, and found: -- `ConstraintMemoryClient` and `McpCalendarClient` β€” the two where the seam - would have earned its keep β€” **are deleted in project 1**. +- `McpCalendarClient` β€” one of the two where the seam would have earned its + keep β€” **is deleted in project 1**. +- `ConstraintMemoryClient` β€” the other one β€” survives; its `workbench=` seam + is the one the spike found earns its keep (0.5s β†’ 17.9s without it) β€” + project 2 adds it. - `TickTickMcpClient` and `NotionMcpClient` **have no workbench**. Their constructor is already pure (URL validation is parsing; the network probe is in `probe()`, which every test monkeypatches). Their three `__new__` @@ -395,7 +396,9 @@ found five times in one converted file and which a rule scoped to production classes would miss β€” a double's state belongs in its `__init__`. An allowlist exists for the legitimate case, each entry naming why; `TBPlan.__new__` is the first entry. Lands in project 1's PR with whatever allowlist the cut -leaves, then shrinks. +leaves, then shrinks. Measured starting point (project 1's guard, per-file +rather than per-line): 34 files, 149 offences (24 `__new__`, 125 private +writes). ### `tests/contracts/` @@ -423,7 +426,9 @@ directory. One PR, after project 1 merges: the builders and the conversion of the literal-heavy files, the four gratuitous `__new__` conversions, the -`tests/contracts/` moves, and the allowlist shrunk to `TBPlan`. +`tests/contracts/` moves, and the allowlist shrunk from its measured +starting point (34 files, 149 offences) toward `TBPlan`, the one entry the +table above says stays legitimate. ### Gate diff --git a/scripts/dev/tests/covdiff.py b/scripts/dev/tests/covdiff.py new file mode 100644 index 00000000..b1bbaf1e --- /dev/null +++ b/scripts/dev/tests/covdiff.py @@ -0,0 +1,101 @@ +"""Per-line coverage diff between two `coverage json` reports. + +Usage: + python covdiff.py + +Run from the directory holding the two report files -- paths are taken +literally (relative or absolute), so a basename resolves against the +caller's cwd. The reports themselves store source paths relative to +wherever pytest ran (typically the repo root), which is rarely the same +place as the two report files when this is run as a one-off diagnostic +from a scratch directory -- so the required third argument names the +root those paths resolve against for the on-disk existence check. There +is no cwd-based default: a report's paths resolving against the wrong +root doesn't fail, it silently resolves every path to "does not exist" +and misfiles every regression under "deleted" instead of "surviving" -- +the exact way this script was once run wrong. To guard against a root +that is merely a *different* wrong directory (also silent), refuse to +run when fewer than half the before-report's paths exist under it. + +For every file the *before* report measured, this compares the set of +covered (`executed_lines`) line numbers against the same file in the +*after* report. A file that dropped out of the after-report entirely +counts as having lost every line it had covered. The verdict that +matters is whether the file still exists on disk: a deleted file losing +its covered lines is expected (nothing new can exercise code that is +gone); a *surviving* file losing covered lines is a regression -- some +line that used to run is no longer reached by any test. + +A surviving file's line diff can also be a false positive if that file +was itself edited between the two coverage captures: an edit shifts +every line after it, so a line merely moved reads as a line lost. This +script cannot tell that apart from a genuine regression -- check `git +diff` on any surviving file this reports before trusting the number. +""" + +from __future__ import annotations + +import json +import pathlib +import sys + + +def _executed(report: dict, path: str) -> set[int]: + entry = report.get("files", {}).get(path) + if not entry: + return set() + return set(entry.get("executed_lines", [])) + + +def main() -> None: + if len(sys.argv) != 4: + print( + f"usage: {sys.argv[0]} ", + file=sys.stderr, + ) + raise SystemExit(2) + + before = json.loads(pathlib.Path(sys.argv[1]).read_text()) + after = json.loads(pathlib.Path(sys.argv[2]).read_text()) + root = pathlib.Path(sys.argv[3]) + + before_paths = sorted(before.get("files", {})) + if before_paths: + resolved = sum(1 for p in before_paths if (root / p).exists()) + if resolved < len(before_paths) / 2: + print( + f"refusing: only {resolved}/{len(before_paths)} of the before-report's " + f"paths exist under source-root {root} -- wrong root, every regression " + "would be misfiled as 'deleted'", + file=sys.stderr, + ) + raise SystemExit(1) + + deleted: list[tuple[str, list[int]]] = [] + surviving: list[tuple[str, list[int]]] = [] + + for path in before_paths: + lost = sorted(_executed(before, path) - _executed(after, path)) + if not lost: + continue + target = (deleted if not (root / path).exists() else surviving) + target.append((path, lost)) + + print(f"before: {len(before.get('files', {}))} files measured") + print(f"after: {len(after.get('files', {}))} files measured") + print() + print(f"=== deleted files that lost covered lines ({len(deleted)}) ===") + for path, lost in deleted: + print(f" {path} (-{len(lost)} lines)") + print() + print(f"=== SURVIVING files that lost covered lines ({len(surviving)}) ===") + if not surviving: + print(" none") + for path, lost in surviving: + shown = ", ".join(str(n) for n in lost[:20]) + more = f" (+{len(lost) - 20} more)" if len(lost) > 20 else "" + print(f" {path}:{shown}{more}") + + +if __name__ == "__main__": + main() diff --git a/scripts/dev/tests/dangling.py b/scripts/dev/tests/dangling.py new file mode 100644 index 00000000..7fe242fb --- /dev/null +++ b/scripts/dev/tests/dangling.py @@ -0,0 +1,62 @@ +"""Every surviving src file that still imports a module I deleted. + +Usage: + python dangling.py # deleted files staged (--cached) + python dangling.py 42d9eb2..HEAD # deleted files over a revision range +""" +import ast, pathlib, subprocess, sys + +if len(sys.argv) > 1: + diff_cmd = ["git", "diff", "--diff-filter=D", "--name-only", sys.argv[1]] +else: + diff_cmd = ["git", "diff", "--cached", "--name-only", "--diff-filter=D"] + +deleted_files = subprocess.run( + diff_cmd, capture_output=True, text=True).stdout.split() +gone = set() +for f in deleted_files: + if not f.startswith("src/") or not f.endswith(".py"): + continue + parts = f[len("src/"):].removesuffix(".py").split("/") + if parts[-1] == "__init__": + parts = parts[:-1] + gone.add(".".join(parts)) + +alive = [p for p in pathlib.Path("src").rglob("*.py") if "__pycache__" not in str(p)] + +def selfmod(p): + parts = list(p.relative_to("src").with_suffix("").parts) + if parts[-1] == "__init__": + parts = parts[:-1] + return ".".join(parts) + +hits = [] +for p in alive: + me = selfmod(p) + ispkg = p.name == "__init__.py" + base = me if ispkg else (me.rsplit(".", 1)[0] if "." in me else me) + try: + tree = ast.parse(p.read_text()) + except SyntaxError: + continue + for n in ast.walk(tree): + targets = [] + if isinstance(n, ast.Import): + targets = [a.name for a in n.names] + elif isinstance(n, ast.ImportFrom): + if n.level: + parts = base.split(".") + up = parts[: len(parts) - (n.level - 1)] if n.level > 1 else parts + m = ".".join(up + ([n.module] if n.module else [])) + else: + m = n.module or "" + targets = [m] + [m + "." + a.name for a in n.names] + for t in targets: + if t in gone: + hits.append((str(p), n.lineno, t)) + break + +print(f"deleted modules: {len(gone)}") +print(f"surviving src files still importing one: {len(set(h[0] for h in hits))}\n") +for f, line, t in sorted(hits): + print(f" {f}:{line} -> {t}") diff --git a/scripts/dev/tests/orphans.py b/scripts/dev/tests/orphans.py new file mode 100644 index 00000000..57e19ca0 --- /dev/null +++ b/scripts/dev/tests/orphans.py @@ -0,0 +1,173 @@ +"""Which `src/` modules are reachable from NO real entry point at all. + +Run from the repository root: + + .venv/bin/python scripts/dev/tests/orphans.py + +An entry point is a module a *process* starts at, so the set is enumerated by +hand from the things that actually launch one -- `[project.scripts]`, the +`python -m ...` commands in `infra/dsh/hooks.json` and +`infra/dsh/profile/cordis.patch.yml`, and everything `scripts/` imports. It +used to be guessed from a module-name suffix (`server`, `runtime`, `bot`, +`app`), which missed every out-of-process MCP server and DSH hook and so +reported ten modules as orphans that a live process starts at. + +`PRE_EXISTING_STUBS` is the empty-or-import-only residue that was already +unreachable before the timeboxing cut; it is listed apart so the headline +number answers "did this change orphan anything", which is the question the +tool exists for.""" +import ast, pathlib + +TOP = ("fateforger", "memory", "tmbx", "trmnl_frontend") +srcroot = pathlib.Path("src") + + +def modname(p): + parts = list(p.relative_to(srcroot).with_suffix("").parts) + if parts[-1] == "__init__": + parts = parts[:-1] + return ".".join(parts) + + +files = {modname(p): p for p in srcroot.rglob("*.py") if "__pycache__" not in str(p)} + + +def imports_of(path, selfmod): + try: + tree = ast.parse(path.read_text()) + except SyntaxError: + return set() + ispkg = path.name == "__init__.py" + base = selfmod if ispkg else (selfmod.rsplit(".", 1)[0] if "." in selfmod else selfmod) + out = set() + for n in ast.walk(tree): + if isinstance(n, ast.Import): + for a in n.names: + out.add(a.name) + elif isinstance(n, ast.ImportFrom): + if n.level: + parts = base.split(".") + up = parts[: len(parts) - (n.level - 1)] if n.level > 1 else parts + m = ".".join(up + ([n.module] if n.module else [])) + out.add(m) + [out.add(m + "." + a.name) for a in n.names] + elif n.module: + out.add(n.module) + [out.add(n.module + "." + a.name) for a in n.names] + return {m for m in out if m.split(".")[0] in TOP} + + +graph = {m: imports_of(p, m) for m, p in files.items()} +for m in list(graph): + parts = m.split(".") + for i in range(1, len(parts)): + pkg = ".".join(parts[:i]) + if pkg in files: + graph[m].add(pkg) + + +def resolve(m): + while m and m not in files: + if "." not in m: + return None + m = m.rsplit(".", 1)[0] + return m + + +extra = set() +for p in list(pathlib.Path("scripts").rglob("*.py")) + list(pathlib.Path(".").glob("*.py")): + try: + tree = ast.parse(p.read_text()) + except Exception: + continue + for n in ast.walk(tree): + if isinstance(n, ast.ImportFrom) and n.module and n.module.split(".")[0] in TOP: + extra.add(n.module) + [extra.add(n.module + "." + a.name) for a in n.names] + elif isinstance(n, ast.Import): + for a in n.names: + if a.name.split(".")[0] in TOP: + extra.add(a.name) + +# Named, because a process starts here. Comments say which one. +NAMED_ENTRIES = { + "fateforger.core.runtime", # the AutoGen host the Slack app boots + "fateforger.slack_bot.bot", # the Slack Bolt app + "fateforger.setup_wizard.app", # the setup wizard + "memory.mcp_server", # the memory MCP server (its own process) + "memory.backfill", # `python -m memory.backfill`, corpus maintenance + "tmbx.server", # `tmbx-mcp` in [project.scripts] + "tmbx.journal.read_api", # the journal read API a host imports out of band + # Out-of-process MCP servers, launched by cordis.patch.yml. + "fateforger.slack_bot.task_board_mcp", + "fateforger.slack_bot.timebox_progress_mcp", + "fateforger.slack_bot.planning_result_mcp", + # DSH hooks, launched as `python -m ...` by infra/dsh/hooks.json. + "fateforger.slack_bot.dsh_timebox_attempt_guard_hook", + "fateforger.slack_bot.dsh_progress_hook", + "fateforger.slack_bot.dsh_commit_gate_hook", +} + +# Unreachable before this work and unrelated to it: empty files and one module +# that is nothing but a block of imports. Reported apart, not deleted here. +PRE_EXISTING_STUBS = { + "", # src/__init__.py -- relative imports that resolve to no package + "fateforger.adapters.slack", # empty + "fateforger.core.bootstrap", # empty + "fateforger.agents.task_marshal", # empty + "fateforger.agents.task_marshal.agent", # imports only, no definitions +} + +# Unreachable after the legacy timeboxing cut and kept anyway, because whether +# each goes is somebody's decision rather than a mechanical consequence. Named +# here so the headline number stays "did this change orphan anything nobody +# looked at", and these stay visible rather than quietly reachable. +KEPT_DESPITE_UNREACHABLE = { + # `_harness_turn` was its only caller and had no caller itself, so it was + # already dead. But `harness_bridge.ask(approval_file=...)` and + # `dsh_commit_gate_hook` still read the file it writes, and it is tested. + "fateforger.slack_bot.thread_approval", + # Last read by the legacy agent's calendar client. Has its own live test. + "fateforger.core.calendar_preferences", +} + +missing = sorted(m for m in NAMED_ENTRIES if m not in files) +if missing: + print("WARNING: named entry points that no longer exist: " + ", ".join(missing)) + +ENTRIES = {r for e in extra if (r := resolve(e))} | (NAMED_ENTRIES & set(files)) + + +def reach(excluded): + seen, stack = set(), [e for e in ENTRIES if e not in excluded] + while stack: + m = stack.pop() + if m in seen or m in excluded: + continue + seen.add(m) + for dep in graph.get(m, ()): + r = resolve(dep) + if r and r not in seen and r not in excluded: + stack.append(r) + return seen + + +reachable = reach(set()) +unreached = set(files) - reachable +orphaned = sorted(unreached - PRE_EXISTING_STUBS - KEPT_DESPITE_UNREACHABLE) +stubs = sorted(unreached & PRE_EXISTING_STUBS) +kept = sorted(unreached & KEPT_DESPITE_UNREACHABLE) +loc = lambda m: sum(1 for _ in files[m].open()) +print(f"modules: {len(files)} reachable: {len(reachable)} STILL ORPHANED: {len(orphaned)}") +if orphaned: + print(f"orphaned LOC: {sum(loc(m) for m in orphaned)}\n") + for m in orphaned: + print(f" {loc(m):5d} {m}") +if kept: + print(f"\nunreachable but kept on purpose: {len(kept)}") + for m in kept: + print(f" {loc(m):5d} {m}") +if stubs: + print(f"\npre-existing stubs (unrelated to this cut): {len(stubs)}") + for m in stubs: + print(f" {loc(m):5d} {m or 'src/__init__.py'}") diff --git a/scripts/dev/tests/taxonomy.py b/scripts/dev/tests/taxonomy.py new file mode 100644 index 00000000..3b1511c3 --- /dev/null +++ b/scripts/dev/tests/taxonomy.py @@ -0,0 +1,97 @@ +"""Classify every test by seam and by how it builds its subject. Mostly AST; +`seam_of` also does plain substring tests on source text, but only for +identifiers this repo itself minted (`OpenRouterJudge`, `OPENROUTER`, +`sqlite`, the `slow` marker) -- never on user content. + +Usage: + python taxonomy.py [out.json] + +Run from the repository root -- it walks `tests/` relative to the cwd. +The seam summary always prints to stdout; `out.json` (default: +`per_test.json`, written beside this script) additionally gets the +per-test `(file, test_name, seam)` rows for downstream tooling. +""" +import ast, pathlib, collections, json, sys + +files = sorted(pathlib.Path("tests").rglob("test_*.py")) + +def calls_in(node): + for n in ast.walk(node): + if isinstance(n, ast.Call): + f = n.func + name = f.attr if isinstance(f, ast.Attribute) else getattr(f, "id", None) + yield name, n + +def seam_of(fn, module_src, module_tree): + """One label per test, first match wins -- ordered from outermost seam inward.""" + names = [n for n, _ in calls_in(fn)] + attrs = [n.attr for n in ast.walk(fn) if isinstance(n, ast.Attribute)] + text = ast.get_source_segment(module_src, fn) or "" + # decorators / markers + decos = [ast.unparse(d) for d in fn.decorator_list] + if any("slow" in d for d in decos) or "OpenRouterJudge" in text or "OPENROUTER" in text: + return "eval" + if "__new__" in names or "monkeypatch" in ast.unparse(fn.args): + # private-attribute surgery on a real class + privates = [a for a in attrs if a.startswith("_") and not a.startswith("__")] + if privates or "__new__" in names: + return "component:privates" + return "component:doubles" + if any(n in names for n in ("AsyncMock", "MagicMock", "patch", "Mock")): + return "component:mocks" + if any(a in ("posted", "updates", "calls", "briefs", "events") for a in attrs) or any( + n and (n.startswith("Recording") or n.startswith("Fake") or n.startswith("Dummy") or n.startswith("_Fake") or n.startswith("_Dummy") or n.startswith("Recorded")) for n in names): + return "component:doubles" + if "create_async_engine" in names or "sqlite" in text: + return "store:sqlite" + return "pure" + +# subject constructors we want to count literal constructions of +CTORS = ("Session", "PlanningSessionSnapshot", "PlanningBrief", "TBPlan", "TBEvent", "TurnRequest", + "PlanningFact", "PlanningDay", "Constraint", "CalendarEvent", "EventDraftPayload", + "PlanningReminder", "Timebox", "PlanningResult", "ArtifactSnapshot") + +seams = collections.Counter(); by_pkg = collections.defaultdict(collections.Counter) +ctor_counts = collections.Counter(); ctor_files = collections.defaultdict(set) +new_usage = collections.Counter(); private_sets = collections.Counter() +per_test = [] +for p in files: + src = p.read_text() + try: t = ast.parse(src) + except SyntaxError: continue + pkg = "/".join(p.parts[1:-1]) or "tests" + for name, call in calls_in(t): + if name in CTORS: + ctor_counts[name] += 1; ctor_files[name].add(str(p)) + if name == "__new__": new_usage[str(p)] += 1 + for n in ast.walk(t): + # agent._private = ... assignments + if isinstance(n, ast.Assign): + for tg in n.targets: + if isinstance(tg, ast.Attribute) and tg.attr.startswith("_") and not tg.attr.startswith("__"): + private_sets[str(p)] += 1 + for n in ast.walk(t): + if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef)) and n.name.startswith("test_"): + s = seam_of(n, src, t) + seams[s] += 1; by_pkg[pkg][s] += 1 + per_test.append((str(p), n.name, s)) + +print("=== SEAM (how each test reaches its subject) ===") +tot = sum(seams.values()) +for s, c in seams.most_common(): print(f" {c:5d} {100*c/tot:4.1f}% {s}") +print("\n=== SEAM x PACKAGE ===") +cols = [s for s,_ in seams.most_common()] +print(" " + " "*28 + "".join(f"{c[:12]:>13s}" for c in cols)) +for pkg in sorted(by_pkg, key=lambda k: -sum(by_pkg[k].values())): + print(f" {pkg:28s}" + "".join(f"{by_pkg[pkg][c]:13d}" for c in cols)) +print("\n=== LITERAL CONSTRUCTIONS of domain objects (DRY evidence) ===") +for n, c in ctor_counts.most_common(): print(f" {c:5d} calls in {len(ctor_files[n]):3d} files {n}(...)") +print("\n=== files using Class.__new__(Class) to dodge __init__ ===") +print(f" {len(new_usage)} files, {sum(new_usage.values())} sites; top:") +for f, c in new_usage.most_common(8): print(f" {c:3d} {f}") +print("\n=== files assigning private attrs (obj._x = ...) ===") +print(f" {len(private_sets)} files, {sum(private_sets.values())} sites; top:") +for f, c in private_sets.most_common(8): print(f" {c:3d} {f}") +out_path = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path(__file__).parent / "per_test.json" +json.dump(per_test, open(out_path, "w")) +print(f"\nper-test rows written to {out_path}") diff --git a/src/fateforger/adapters/calendar/models.py b/src/fateforger/adapters/calendar/models.py deleted file mode 100644 index 751d9854..00000000 --- a/src/fateforger/adapters/calendar/models.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations - -from typing import Any, Optional - -from pydantic import BaseModel, ConfigDict, Field - - -class GCalEventDateTime(BaseModel): - """Google Calendar event start/end payload (timed or all-day).""" - - model_config = ConfigDict(extra="allow", populate_by_name=True) - - date_time: Optional[str] = Field(default=None, alias="dateTime") # RFC3339 - date: Optional[str] = None # YYYY-MM-DD (all-day) - time_zone: Optional[str] = Field(default=None, alias="timeZone") - - -class GCalPerson(BaseModel): - """Google Calendar creator/organizer payload.""" - - model_config = ConfigDict(extra="allow", populate_by_name=True) - - email: Optional[str] = None - self_: Optional[bool] = Field(default=None, alias="self") - - -class GCalReminders(BaseModel): - """Google Calendar reminders payload.""" - - model_config = ConfigDict(extra="allow", populate_by_name=True) - - use_default: Optional[bool] = Field(default=None, alias="useDefault") - overrides: Optional[list[dict[str, Any]]] = None - - -class GCalEvent(BaseModel): - """Google Calendar event resource (subset + extra passthrough).""" - - model_config = ConfigDict(extra="allow", populate_by_name=True) - - id: str - summary: Optional[str] = None - start: GCalEventDateTime - end: GCalEventDateTime - status: Optional[str] = None - html_link: Optional[str] = Field(default=None, alias="htmlLink") - created: Optional[str] = None - updated: Optional[str] = None - creator: Optional[GCalPerson] = None - organizer: Optional[GCalPerson] = None - ical_uid: Optional[str] = Field(default=None, alias="iCalUID") - sequence: Optional[int] = None - reminders: Optional[GCalReminders] = None - event_type: Optional[str] = Field(default=None, alias="eventType") - guests_can_modify: Optional[bool] = Field(default=None, alias="guestsCanModify") - calendar_id: Optional[str] = Field(default=None, alias="calendarId") - account_id: Optional[str] = Field(default=None, alias="accountId") - - -class GCalEventsResponse(BaseModel): - """List events response shape you pasted.""" - - model_config = ConfigDict(extra="allow", populate_by_name=True) - - events: list[GCalEvent] - total_count: int = Field(alias="totalCount") diff --git a/src/fateforger/agents/admonisher/__init__.py b/src/fateforger/agents/admonisher/__init__.py index 1a7cdf66..09858166 100644 --- a/src/fateforger/agents/admonisher/__init__.py +++ b/src/fateforger/agents/admonisher/__init__.py @@ -1,9 +1,4 @@ -from .base import BaseHaunter -from .calendar import CalendarHaunter -from .commitment import CommitmentHaunter +"""Admonisher package. The three haunter classes it used to re-export went +with the legacy timeboxing agent; `agent` and `models` are imported directly.""" -__all__ = [ - "BaseHaunter", - "CalendarHaunter", - "CommitmentHaunter", -] +__all__: list[str] = [] diff --git a/src/fateforger/agents/admonisher/base.py b/src/fateforger/agents/admonisher/base.py deleted file mode 100644 index 71bcc110..00000000 --- a/src/fateforger/agents/admonisher/base.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Common Haunter utilities.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from datetime import datetime, timedelta -from typing import Callable, Optional - -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from slack_sdk.web.async_client import AsyncWebClient - -from fateforger.core.logging import get_logger -from fateforger.core.slack import delete_scheduled, schedule_dm - -from ...haunt.models import FollowUpPlan, HauntDirection, HauntEnvelope, HauntTone -from ...haunt.orchestrator import HauntOrchestrator, HauntTicket - - -class BaseHaunter(ABC): - """Base class with Slack and scheduler helpers.""" - - def __init__( - self, - session_id: int, - slack: AsyncWebClient, - scheduler: AsyncIOScheduler, - channel: str, - *, - orchestrator: Optional[HauntOrchestrator] = None, - haunt_agent_id: Optional[str] = None, - backoff_base_minutes: int = 5, - backoff_cap_minutes: int = 120, - ) -> None: - self.session_id = session_id - self.slack = slack - self.scheduler = scheduler - self.channel = channel - self.logger = get_logger(self.__class__.__name__) - self.haunt = orchestrator - self.haunt_agent_id = haunt_agent_id or f"{self.__class__.__name__}:{session_id}" - - if self.haunt: - self.haunt.register_agent( - self.haunt_agent_id, - callback=self._handle_ticket, - backoff_base_minutes=backoff_base_minutes, - backoff_cap_minutes=backoff_cap_minutes, - ) - - def schedule_job( - self, job_id: str, when: datetime, fn: Callable, *args, **kwargs - ) -> None: - self.scheduler.add_job( - fn, - trigger="date", - run_date=when, - args=args, - kwargs=kwargs, - id=job_id, - replace_existing=True, - ) - - async def send(self, text: str) -> str: - resp = await self.slack.chat_postMessage(channel=self.channel, text=text) - await self._log_outbound( - content=text, - core_intent=text, - follow_up=FollowUpPlan(required=False), - tone=HauntTone.NEUTRAL, - metadata={}, - message_ref=resp["ts"], - ) - return resp["ts"] - - async def schedule_slack(self, text: str, when: datetime) -> str: - post_at = int(when.timestamp()) - scheduled_id = await schedule_dm(self.slack, self.channel, text, post_at) - delay_seconds = max(int((when - datetime.utcnow()).total_seconds()), 0) - delay_minutes = max((delay_seconds + 59) // 60, 1) - - await self._log_outbound( - content=text, - core_intent=text, - follow_up=FollowUpPlan(required=True, delay_minutes=delay_minutes), - tone=HauntTone.NEUTRAL, - metadata={"scheduled_post_at": post_at}, - message_ref=scheduled_id, - ) - return scheduled_id - - async def delete_scheduled(self, scheduled_id: str) -> None: - await delete_scheduled(self.slack, self.channel, scheduled_id) - if self.haunt: - await self.haunt.acknowledge(str(self.session_id), self.haunt_agent_id) - - @staticmethod - def next_run_time(attempt: int, base: int = 5, cap: int = 120) -> datetime: - delay = min(base * (2**attempt), cap) - return datetime.utcnow() + timedelta(minutes=delay) - - @staticmethod - def next_delay(attempt: int, base: int = 5, cap: int = 120) -> int: - """Return delay in minutes using exponential backoff.""" - return min(base * (2**attempt), cap) - - @abstractmethod - async def handle_reply(self, text: str) -> None: - pass - - async def _log_inbound(self, text: str, *, core_intent: Optional[str] = None) -> None: - if not self.haunt: - return - envelope = HauntEnvelope( - session_id=str(self.session_id), - agent_id=self.haunt_agent_id, - channel=self.channel, - direction=HauntDirection.INBOUND, - content=text, - core_intent=core_intent or text, - tone=HauntTone.NEUTRAL, - ) - await self.haunt.record_envelope(envelope) - - async def _log_outbound( - self, - *, - content: str, - core_intent: str, - follow_up: FollowUpPlan, - tone: HauntTone, - metadata: dict, - message_ref: Optional[str] = None, - ) -> None: - if not self.haunt: - return - envelope = HauntEnvelope( - session_id=str(self.session_id), - agent_id=self.haunt_agent_id, - channel=self.channel, - direction=HauntDirection.OUTBOUND, - content=content, - core_intent=core_intent, - tone=tone, - follow_up=follow_up, - metadata=metadata, - message_ref=message_ref, - ) - await self.haunt.record_envelope(envelope) - - async def _handle_ticket(self, ticket: HauntTicket) -> None: - await self.handle_follow_up(ticket) - - async def handle_follow_up(self, ticket: HauntTicket) -> None: - """Derived classes can override to respond to follow-up triggers.""" - self.logger.debug("No follow-up handler implemented for %s", self.haunt_agent_id) diff --git a/src/fateforger/agents/admonisher/calendar.py b/src/fateforger/agents/admonisher/calendar.py deleted file mode 100644 index d217d9c3..00000000 --- a/src/fateforger/agents/admonisher/calendar.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Calendar Haunter - AutoGen MCP Calendar integration for FateForger.""" - -from __future__ import annotations - -import asyncio -import datetime as dt -from typing import Any, List, Optional, Union - -from autogen_agentchat.agents import AssistantAgent -from autogen_agentchat.messages import TextMessage -from autogen_core import CancellationToken -from autogen_ext.tools.mcp import StreamableHttpServerParams, mcp_server_tools - -from ...core.config import settings -from ...core.logging import get_logger -from fateforger.llm import build_autogen_chat_client -from .base import BaseHaunter -from .prompts import ADMONISHER_PERSONA_PROMPT - -logger = get_logger(__name__) - - -class CalendarHaunter(BaseHaunter): - """AutoGen-powered calendar haunter with real Google Calendar access via MCP. - - Uses StreamableHttpServerParams to bypass broken SSE transport, - providing full calendar functionality through MCP protocol. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._agent: Optional[AssistantAgent] = None - self._mcp_server_url = settings.mcp_calendar_server_url - - async def _ensure_agent(self) -> AssistantAgent: - """Lazy-load the AutoGen MCP calendar agent.""" - if self._agent is None: - self._agent = await self._create_calendar_agent() - return self._agent - - async def _create_calendar_agent(self) -> AssistantAgent: - """Create AutoGen agent with real Google Calendar MCP tools via HTTP transport. - - Returns: - Fully configured AssistantAgent with 9 Google Calendar tools - - Raises: - RuntimeError: If MCP server is unavailable or tools can't be loaded - """ - try: - logger.info("πŸ”§ Configuring MCP HTTP transport") - - # Use HTTP transport (bypasses broken SSE) - params = StreamableHttpServerParams( - url=self._mcp_server_url, - timeout=10.0, - ) - - if not settings.openai_api_key: - raise RuntimeError("OpenAI API key not configured") - - logger.info(f"πŸ“‘ Loading calendar tools from {self._mcp_server_url}") - tools = await mcp_server_tools(params) - - if not tools: - raise RuntimeError( - "No MCP calendar tools loaded - server may be unavailable" - ) - - logger.info( - f"πŸ› οΈ Loaded {len(tools)} calendar tools: {[getattr(t, 'name', str(t)[:30]) for t in tools[:3]]}..." - ) - - agent = AssistantAgent( - name="CalendarHaunter", - model_client=build_autogen_chat_client( - "admonisher_agent", parallel_tool_calls=False - ), - system_message=f""" -{ADMONISHER_PERSONA_PROMPT} - -You are a calendar haunter in the FateForger system. Today is {dt.date.today().isoformat()}. -Use your Google Calendar tools to help the user manage their schedule. Be precise and proactive. -""".strip(), - tools=tools, # type: ignore - MCP tools are compatible - ) - - logger.info("βœ… Calendar haunter agent created successfully") - return agent - - except Exception as e: - logger.error(f"Failed to create calendar agent: {e}") - raise RuntimeError(f"Calendar haunter initialization failed: {e}") from e - - async def ask_calendar_question(self, question: str) -> str: - """Ask the calendar agent a question and return the response. - - Args: - question: Natural language question about calendar - - Returns: - Agent's response as plain text - - Raises: - RuntimeError: If agent fails to respond - """ - try: - agent = await self._ensure_agent() - - logger.info(f"❓ Calendar question: {question}") - - message = TextMessage(content=question, source="user") - response = await agent.on_messages([message], CancellationToken()) - - # Extract text content from response - content = getattr( - response.chat_message, "content", str(response.chat_message) - ) - if isinstance(content, list) and content: - # Handle structured response format - text_parts = [ - item.get("text", str(item)) - for item in content - if isinstance(item, dict) - ] - answer = "\n".join(text_parts) if text_parts else str(content[0]) - else: - answer = str(content) - - logger.info(f"πŸ’¬ Calendar response ({len(answer)} chars)") - return answer - - except Exception as e: - logger.error(f"Calendar question failed: {e}") - raise RuntimeError(f"Failed to get calendar response: {e}") from e - - async def get_todays_events(self) -> str: - """Get today's calendar events.""" - today = dt.date.today().isoformat() - return await self.ask_calendar_question( - f"What events do I have today ({today})?" - ) - - async def get_weekly_schedule(self) -> str: - """Get this week's calendar schedule.""" - return await self.ask_calendar_question( - "What's my schedule looking like this week?" - ) - - async def list_calendars(self) -> str: - """List available calendars.""" - return await self.ask_calendar_question( - "Can you list all my Google Calendar calendars?" - ) - - async def search_events(self, query: str) -> str: - """Search for events containing specific terms.""" - return await self.ask_calendar_question( - f"Search my calendar for events containing: {query}" - ) - - async def create_event( - self, title: str, start_time: str, description: Optional[str] = None - ) -> str: - """Create a new calendar event.""" - event_details = f"Create a calendar event titled '{title}' at {start_time}" - if description: - event_details += f" with description: {description}" - return await self.ask_calendar_question(event_details) - - async def handle_reply(self, text: str) -> None: - """Handle user replies - forward to calendar agent.""" - try: - response = await self.ask_calendar_question(text) - await self.send(f"πŸ“… Calendar Assistant: {response}") - - except Exception as e: - logger.error(f"Failed to handle calendar reply: {e}") - await self.send( - "❌ Sorry, I'm having trouble accessing your calendar right now." - ) - - -async def create_calendar_haunter_agent() -> AssistantAgent: - """Standalone function to create calendar agent for testing/external use. - - Returns: - Configured AssistantAgent with Google Calendar tools - """ - mcp_server_url = settings.mcp_calendar_server_url - if not (settings.openrouter_api_key or settings.openai_api_key): - raise RuntimeError( - "No LLM API key configured. Set OPENAI_API_KEY or OPENROUTER_API_KEY." - ) - - # Configure HTTP transport (bypasses broken SSE) - params = StreamableHttpServerParams(url=mcp_server_url, timeout=10.0) - - # Fetch real Google Calendar tools - tools = await mcp_server_tools(params) - - if not tools: - raise RuntimeError(f"No tools loaded from MCP server at {mcp_server_url}") - - # Create AutoGen agent with real tools - return AssistantAgent( - name="CalendarAgent", - model_client=build_autogen_chat_client("admonisher_agent"), - system_message=f""" -{ADMONISHER_PERSONA_PROMPT} - -You are a calendar assistant. Today is {dt.date.today().isoformat()}. -""".strip(), - tools=tools, # type: ignore - MCP tools are compatible - ) diff --git a/src/fateforger/agents/admonisher/commitment.py b/src/fateforger/agents/admonisher/commitment.py deleted file mode 100644 index 76d12788..00000000 --- a/src/fateforger/agents/admonisher/commitment.py +++ /dev/null @@ -1,159 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timedelta -from typing import Iterable, Optional - -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from slack_sdk.web.async_client import AsyncWebClient -from sqlalchemy.ext.asyncio import AsyncSession - -from ...haunt.models import CalendarHook, FollowUpPlan, HauntTone -from ...haunt.orchestrator import HauntOrchestrator, HauntTicket -from ..schedular.models.calendar import CalendarEvent -from .base import BaseHaunter - - -class CommitmentHaunter(BaseHaunter): - """Minimal commitment haunter for tests.""" - - def __init__( - self, - session_id: int, - slack: AsyncWebClient, - scheduler: AsyncIOScheduler, - db: AsyncSession, - orchestrator: HauntOrchestrator, - channel: str = "D123", - ) -> None: - super().__init__( - session_id, - slack, - scheduler, - channel, - orchestrator=orchestrator, - backoff_base_minutes=10, - backoff_cap_minutes=180, - ) - self.db = db - self.scheduled_id: str | None = None - self._calendar_hooks: dict[str, HauntTicket] = {} - - async def remind( - self, - *, - prompt: str = "Are you still on track for your commitment?", - delay_minutes: int = 2, - follow_up: Optional[FollowUpPlan] = None, - ) -> None: - """Send an initial nudge and register follow-up expectations.""" - - when = datetime.utcnow() + timedelta(minutes=delay_minutes) - plan = follow_up or FollowUpPlan(required=True, delay_minutes=delay_minutes) - if plan.required and not follow_up: - # ensure we respect the exponential backoff base configured for this haunter - plan = plan.model_copy(update={"delay_minutes": delay_minutes}) - - self.scheduled_id = await self.schedule_slack(prompt, when) - # schedule_slack already registers the envelope with the orchestrator using plan - - async def handle_follow_up(self, ticket: HauntTicket) -> None: - """Dispatch follow-up pings when the orchestrator fires.""" - - payload = ticket.payload - metadata = payload.metadata - tone = payload.tone - - if metadata.get("calendar_event_id"): - title = metadata.get("title", payload.core_intent) - message = self._format_calendar_follow_up(title, tone) - else: - message = self._format_generic_follow_up(payload.core_intent, tone) - - await self.send(message) - - async def handle_reply(self, text: str) -> None: - await self._log_inbound(text) - if text == "mark_done" and self.scheduled_id: - await self.delete_scheduled(self.scheduled_id) - self.scheduler.remove_all_jobs() - - async def sync_calendar_events( - self, events: Iterable[CalendarEvent] - ) -> list[HauntTicket]: - """Register/update follow-up timers for upcoming calendar commitments.""" - - if not self.haunt: - return [] - - tickets: list[HauntTicket] = [] - for event in events: - start_at = self._event_start(event) - if not start_at: - self.logger.debug( - "Skipping calendar event %s because it has no start time", event.summary - ) - continue - - hook = CalendarHook( - session_id=str(self.session_id), - agent_id=self.haunt_agent_id, - event_id=event.eventId or event.summary, - title=event.summary, - start_at=start_at, - end_at=self._event_end(event), - tone=HauntTone.SUPPORTIVE, - metadata={ - "channel": self.channel, - "calendar_event_id": event.eventId or event.summary, - "title": event.summary, - "description": event.description, - }, - ) - - ticket = await self.haunt.schedule_calendar_hook(hook) - self._calendar_hooks[hook.event_id] = ticket - tickets.append(ticket) - - return tickets - - @staticmethod - def _event_start(event: CalendarEvent) -> Optional[datetime]: - start = getattr(event, "start", None) - if isinstance(start, datetime): - return start - - # Fallback: combine date + start_time if available - date_part = getattr(event, "start", None) - time_part = getattr(event, "start_time", None) - if date_part and time_part: - return datetime.combine(date_part, time_part) - return None - - @staticmethod - def _event_end(event: CalendarEvent) -> Optional[datetime]: - end = getattr(event, "end", None) - if isinstance(end, datetime): - return end - - date_part = getattr(event, "end", None) - time_part = getattr(event, "end_time", None) - if date_part and time_part: - return datetime.combine(date_part, time_part) - return None - - def _format_generic_follow_up(self, core_intent: str, tone: HauntTone) -> str: - prefix = self._tone_prefix(tone) - return f"{prefix} Still on it? {core_intent}" - - def _format_calendar_follow_up(self, title: str, tone: HauntTone) -> str: - prefix = self._tone_prefix(tone) - return f"{prefix} {title} is starting now β€” shall we get it rolling?" - - @staticmethod - def _tone_prefix(tone: HauntTone) -> str: - return { - HauntTone.ASSERTIVE: "⚑️", - HauntTone.ENCOURAGING: "✨", - HauntTone.SUPPORTIVE: "🀝", - HauntTone.PLAYFUL: "🎭", - }.get(tone, "πŸ‘»") diff --git a/src/fateforger/agents/schedular/diffing_agent.py b/src/fateforger/agents/schedular/diffing_agent.py deleted file mode 100644 index 3b3985dd..00000000 --- a/src/fateforger/agents/schedular/diffing_agent.py +++ /dev/null @@ -1,312 +0,0 @@ -""" -Planner Agent - Structured JSON output calendar planning agent for AutoGen Sequential Workflow. - -Implements Ticket #2: Uses json_output=PlanDiff for structured output and integrates -list-events MCP tool for diff-against-calendar logic. -""" - -import json -from typing import List, Dict, Any, Optional -from datetime import datetime, timezone - -from autogen_agentchat.agents import AssistantAgent -from autogen_agentchat.messages import TextMessage -from autogen_core import CancellationToken -from autogen_core.tools import FunctionTool -from autogen_ext.tools.mcp import mcp_server_tools - -from fateforger.contracts import CalendarEvent, CalendarOp, OpType, PlanDiff -from fateforger.core.config import settings -from fateforger.llm import ( - assert_strict_tools_for_structured_output, - build_autogen_chat_client, -) -from ...tools_config import get_calendar_mcp_params - - -# TODO: use deepdiff for this instead -class PlannerAgentFactory: - """Factory for creating PlannerAgent with structured output and calendar diffing.""" - - @staticmethod - async def create() -> AssistantAgent: - """ - Create PlannerAgent with structured JSON output and calendar tools. - - Returns: - AssistantAgent configured with json_output=PlanDiff and list-events tool - """ - if not (settings.openrouter_api_key or settings.openai_api_key): - raise RuntimeError( - "No LLM API key configured. Set OPENAI_API_KEY or OPENROUTER_API_KEY." - ) - - # Load MCP calendar tools - params = get_calendar_mcp_params(timeout=10.0) - tools = await mcp_server_tools(params) - - # Find the list-events tool - raw_list_events_tool = next( - ( - tool - for tool in tools - if hasattr(tool, "name") and tool.name == "list-events" - ), - None, - ) - if not raw_list_events_tool: - raise RuntimeError("list-events tool not found in MCP server tools") - - async def list_events( - calendarId: str, - timeMin: str, - timeMax: str, - singleEvents: bool, - orderBy: str, - ) -> dict[str, Any]: - """Strict wrapper around MCP `list-events` with explicit JSON parsing.""" - result = await raw_list_events_tool.run_json( - { - "calendarId": calendarId, - "timeMin": timeMin, - "timeMax": timeMax, - "singleEvents": singleEvents, - "orderBy": orderBy, - }, - CancellationToken(), - ) - if isinstance(result, dict): - return result - text_payload = raw_list_events_tool.return_value_as_string(result) - if not text_payload: - raise RuntimeError("list-events returned empty payload") - try: - decoded = json.loads(text_payload) - except Exception as exc: - raise RuntimeError( - f"list-events returned non-JSON payload: {text_payload}" - ) from exc - if isinstance(decoded, list) and decoded: - first = decoded[0] - if isinstance(first, dict) and first.get("type") == "text": - text = first.get("text") - if isinstance(text, str): - try: - parsed = json.loads(text) - except Exception as exc: - raise RuntimeError( - f"list-events text payload is non-JSON: {text}" - ) from exc - if isinstance(parsed, dict): - return parsed - if isinstance(decoded, dict): - return decoded - raise RuntimeError( - f"list-events returned unexpected payload shape: {type(decoded).__name__}" - ) - - strict_list_events_tool = FunctionTool( - list_events, - name="list_events", - description=( - "List calendar events in a time range. Returns JSON with events/items." - ), - strict=True, - ) - - # Create agent with structured output - assert_strict_tools_for_structured_output( - tools=[strict_list_events_tool], - output_content_type=PlanDiff, - agent_name="PlannerAgent", - ) - agent = AssistantAgent( - name="PlannerAgent", - model_client=build_autogen_chat_client( - "planner_agent", parallel_tool_calls=False - ), - tools=[strict_list_events_tool], - output_content_type=PlanDiff, # Structured output into PlanDiff - system_message=""" -You are PlannerAgent for calendar planning with structured JSON output. - -WORKFLOW: -1. You receive desired_slots as JSON: a list of CalendarEvent objects the user wants on their calendar -2. FIRST: Call the list_events tool to fetch current calendar events (use calendarId="primary") -3. THEN: Compute a PlanDiff by comparing desired_slots to current events -4. RETURN: Only the PlanDiff JSON structure - no extra text or explanation - -DIFF LOGIC: -- CREATE: desired events not in current calendar (match by id, or by summary+start time if no id) -- UPDATE: events with same id but different fields (summary, start, end, etc.) -- DELETE: current events not in desired_slots - -TIME RANGE: Use timeMin/timeMax in list-events to cover the span of desired_slots. - -OUTPUT FORMAT: Return ONLY the PlanDiff JSON structure matching this schema: -{ - "operations": [ - { - "op": "create|update|delete", - "event": {...}, // for CREATE - "event_id": "...", // for UPDATE/DELETE - "diff": {...} // for UPDATE - } - ] -} - -NO prose, NO explanations - just the JSON. -""", - ) - - return agent - - @staticmethod - async def plan_calendar_changes( - agent: AssistantAgent, desired_slots: List[CalendarEvent] - ) -> PlanDiff: - """ - Get a PlanDiff from the agent given desired calendar slots. - - Args: - agent: PlannerAgent instance from create() - desired_slots: List of CalendarEvent objects representing desired calendar state - - Returns: - PlanDiff with operations to transform calendar to match desired_slots - """ - # Convert desired slots to JSON for the agent - desired_slots_json = json.dumps( - [event.model_dump(by_alias=True) for event in desired_slots], - default=str, - indent=2, - ) - - # Send planning request - message = TextMessage(content=f"PLAN: {desired_slots_json}", source="user") - - response = await agent.on_messages([message], CancellationToken()) - - # Handle the response - with output_content_type, it should be structured - try: - # Try to extract content from response - if hasattr(response, "chat_message") and hasattr( - response.chat_message, "content" - ): - content = getattr(response.chat_message, "content") - if isinstance(content, PlanDiff): - return content - else: - # Try to convert to PlanDiff - return PlanDiff.model_validate(content) - elif isinstance(response, PlanDiff): - return response - else: - # Last resort: try to validate the response as PlanDiff - return PlanDiff.model_validate(response) # type: ignore - except Exception as e: - raise RuntimeError( - f"Failed to extract PlanDiff from agent response: {e}" - ) from e - - -def compute_time_range(desired_slots: List[CalendarEvent]) -> tuple[str, str]: - """ - Compute timeMin/timeMax ISO strings from desired slots. - - Args: - desired_slots: List of CalendarEvent objects - - Returns: - Tuple of (timeMin, timeMax) in ISO 8601 format - """ - if not desired_slots: - # Default to current week if no slots - now = datetime.now(timezone.utc) - time_min = now.replace(hour=0, minute=0, second=0, microsecond=0) - time_max = time_min.replace(hour=23, minute=59, second=59) - else: - # Find min/max times from slots - start_times = [] - end_times = [] - - for slot in desired_slots: - if slot.start and slot.start.date_time: - start_times.append(slot.start.date_time) - if slot.end and slot.end.date_time: - end_times.append(slot.end.date_time) - - if start_times and end_times: - time_min = min(start_times) - time_max = max(end_times) - else: - # Fallback to current day - now = datetime.now(timezone.utc) - time_min = now.replace(hour=0, minute=0, second=0, microsecond=0) - time_max = time_min.replace(hour=23, minute=59, second=59) - - return time_min.isoformat(), time_max.isoformat() - - -# TODO: replace with deepdiff -def compute_plan_diff( - desired_slots: List[CalendarEvent], current_events: List[Dict[str, Any]] -) -> PlanDiff: - """ - Compute PlanDiff operations to transform current calendar to match desired slots. - - This is a reference implementation of the diff algorithm. The actual diffing - should be done by the LLM agent for better context awareness. - - Args: - desired_slots: Desired calendar state - current_events: Current calendar events from list-events tool - - Returns: - PlanDiff with required operations - """ - operations = [] - - # Index current events by ID - current_index = { - event.get("id"): event for event in current_events if event.get("id") - } - - # Track desired event IDs - desired_ids = {slot.id for slot in desired_slots if slot.id} - - # 1. Detect CREATES and UPDATES - for desired in desired_slots: - if desired.id and desired.id in current_index: - # Potential UPDATE - compare fields - current = current_index[desired.id] - diff = {} - - # Compare key fields - if desired.summary != current.get("summary"): - diff["summary"] = desired.summary - if desired.description != current.get("description"): - diff["description"] = desired.description - if desired.location != current.get("location"): - diff["location"] = desired.location - - # Add more field comparisons as needed - - if diff: - operations.append( - CalendarOp(op=OpType.UPDATE, event_id=desired.id, diff=diff) - ) # type: ignore - all fields are optional in CalendarOp - else: - # CREATE - event doesn't exist - operations.append( - CalendarOp(op=OpType.CREATE, event=desired) - ) # type: ignore - all fields are optional in CalendarOp - - # 2. Detect DELETES - for existing_id, existing_event in current_index.items(): - if existing_id not in desired_ids: - operations.append( - CalendarOp(op=OpType.DELETE, event_id=existing_id) - ) # type: ignore - all fields are optional in CalendarOp - - return PlanDiff(operations=operations) diff --git a/src/fateforger/agents/shared/handoff_policy.py b/src/fateforger/agents/shared/handoff_policy.py deleted file mode 100644 index b1d70f63..00000000 --- a/src/fateforger/agents/shared/handoff_policy.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Shared handoff policy helpers for specialist routing.""" - -from __future__ import annotations - -from enum import Enum - -from pydantic import BaseModel, Field - - -class HandoffRoute(str, Enum): - """Deterministic routing result for an assist/handoff request.""" - - STAY_CURRENT = "stay_current" - HANDOFF = "handoff" - - -class HandoffIntent(BaseModel): - """Typed handoff intent emitted by an agent decision model.""" - - action: str - target: str | None = None - confidence: float | None = Field(default=None, ge=0.0, le=1.0) - - -class HandoffPolicy(BaseModel): - """Shared handoff gate that enforces clear intent before routing away.""" - - allowed_targets: set[str] = Field(default_factory=set) - min_confidence: float = Field(default=0.8, ge=0.0, le=1.0) - - def resolve(self, intent: HandoffIntent) -> HandoffRoute: - """Return HANDOFF only when target + confidence are explicit and valid.""" - target = (intent.target or "").strip() - match ( - intent.action == "assist", - target in self.allowed_targets, - intent.confidence is not None - and intent.confidence >= self.min_confidence, - ): - case (True, True, True): - return HandoffRoute.HANDOFF - case _: - return HandoffRoute.STAY_CURRENT - - -__all__ = ["HandoffIntent", "HandoffPolicy", "HandoffRoute"] diff --git a/src/fateforger/agents/timeboxing/AGENTS.md b/src/fateforger/agents/timeboxing/AGENTS.md index 562bde13..c11d91c8 100644 --- a/src/fateforger/agents/timeboxing/AGENTS.md +++ b/src/fateforger/agents/timeboxing/AGENTS.md @@ -3,127 +3,46 @@ **Scope:** Operational rules for the `src/fateforger/agents/timeboxing/` subtree. For file index, architecture, and status, see `README.md` in this folder. -## Goals - -- Keep the timeboxing flow responsive; never block user replies on durable preference writes. -- Extract session-scoped constraints from user replies (not from generic "start timeboxing" requests). -- Prefetch durable constraints from Notion (via constraint-memory MCP) before Stage 1 so the cache is warm (uses the gap-driven `ConstraintRetriever`). -- Stage-gating LLMs must not call tools; the coordinator handles all tool IO in background tasks. -- Intent classification and natural-language interpretation must use LLMs (AutoGen agents) or explicit Slack slash commands; do not use regex/keyword matching. -- Handoffs are gated by typed intent fields (`assist_target`, `assist_confidence`): if unclear, stay in the current agent/stage by default. -- Plan in block-based terms (deep/shallow blocks, energy windows); time estimates are optional. -- Each stage agent has a single responsibility and a typed input/output contract; avoid prompt overlap. -- The coordinator is the only place that assembles context (facts + constraints + immovables) and passes it forward. - -## Invariants - -- Keep orchestration constants out of `agent.py`; use `constants.py` (timeouts/limits/fallbacks). -- Keep parsing/validation DRY; use `pydantic_parsing.py` helpers for LLM outputs and mixed payloads. -- Prefer Pydantic validation for Slack/MCP/Notion payloads; avoid try/except parsing and manual dict probing. -- Legacy/back-compat code must be marked with `# TODO(refactor):` and removed after migration. -- Keep MCP wiring out of `agent.py`; use `mcp_clients.py` for calendar/constraint-memory clients. -- Durable constraint retrieval is centralized in `constraint_retriever.py` (query_types -> type_ids -> query_constraints). -- Inject list-shaped prompt data via TOON tables (not JSON arrays); see `src/fateforger/llm/toon.py` and `toon_views.py`. -- Stage 5 submit parity is mandatory: NL submit intent and button submit must converge to the same submission executor path (currently `_submit_pending_plan`). -- Stage 1's gate (`stage1_gate` in `elicitation.py`) is arithmetic over the session snapshot and never calls a model; only the three judges in `elicitation_judges.py` (via `elicit()`) touch a model client, and only from the Slack host's `resolve()`. +> **Retired (2026-09-09).** Most of what this file used to say rules for β€” +> the coordinator (`agent.py`), GraphFlow orchestration (`flow_graph.py`, +> `nodes/`), the LLM-facing plan/patch models (`tb_models.py`, `tb_ops.py`), +> the sync engine (`sync_engine.py`, `submitter.py`), the schema-in-prompt +> patcher (`patching.py`), and the Notion-backed NLU/constraint plumbing +> (`nlu.py`, `constraint_retriever.py`, `constraint_search_tool.py`, +> `notion_constraint_extractor.py`) β€” is deleted code +> (`refactor: retire TimeboxingFlowAgent and the 34 modules only it +> reached`, commit `67489cd`; see the top of `README.md` for the full list). +> Below are the rules that still apply to what remains: the Stage 1 +> elicitation loop and the durable constraint-memory backends. Everything +> those old sections said about the deleted subsystems is not repeated here +> as a historical record β€” read `67489cd^` for that, the way the two root +> calendar docs point at their own predecessor code instead of re-describing +> it. + +## Stage 1 Elicitation (elicitation.py, elicitation_judges.py) + +- Stage 1's gate (`stage1_gate` in `elicitation.py`) is arithmetic over the session snapshot and never calls a model; only the three judges in `elicitation_judges.py` (via `elicit()`) touch a model client, and only from the Slack host's `resolve()` in `slack_bot/timeboxing_host.py`. - A cell whose probe was answered (an `ELICITED_STATEMENT` fact carrying that cell id) or assumed past (a `PlannerAssumption`) is never asked again. `closed_cells` is the single source of that subtraction; read it there rather than re-deriving "closed" at a call site, or the gate could disagree with itself about whether a cell is still open depending on who asked. - A Stage 1 judge failure (a bad schema, an index the model was not offered, an empty option label) propagates out of `elicit()` rather than degrading to a smaller matrix or a silently skipped cell. A host that cannot judge fails the turn instead of proposing to close a stage it never opened. - `elicit()`'s classify batch runs every open cell concurrently and completes in full -- via `asyncio.gather`, so any one failure fails the whole batch -- before the coverage matrix is assembled and written to the snapshot. Nothing reads a matrix that is still being built. -## Framework First (Don't Reinvent It) +## Durable Constraint Memory (mcp_clients.py, graphiti_constraint_memory.py, constraint_record_memory.py, kg_constraint_client.py, durable_constraint_store.py) -- Prefer AutoGen capabilities for workflow control and routing: - - `GraphFlow` / `DiGraphBuilder` for stage machines (see `flow_graph.py`, `nodes/nodes.py`). - - Termination conditions (one user-facing message per Slack turn). - - Typed outputs via `output_content_type` where the schema has no `oneOf` / discriminated unions. - - Tools via `FunctionTool` / MCP clients (tool IO stays in the coordinator). -- Prefer structured message types over bespoke dict protocols (Pydantic models + `StructuredMessage`). +- These backends are read by `fateforger.agents.tasks.defaults_memory` (tasks' defaults memory) and by `fateforger.core.runtime`'s startup checks via `settings.timeboxing_memory_backend` β€” not by any coordinator in this directory, which no longer exists. +- `mcp_clients.py` now holds only `ConstraintMemoryClient` (the constraint-memory MCP stdio client); `McpCalendarClient` was deleted with the coordinator. +- `kg_constraint_client.py` is read-only, deliberately: a constraint in the standalone memory server's store (`data/memory.db`) is L2 -- never authored directly, always projected from the immutable observation log -- so writing a row straight into that store would bypass the projection that makes re-projection-on-judgement-improvement possible. +- `durable_constraint_store.py` defines the backend-neutral `DurableConstraintStore` protocol the concrete clients above implement; keep new durable-memory backends behind that same interface rather than special-casing a backend name at a call site. ## Forbidden: Deterministic NLU - Do not add deterministic extraction/interpretation of user intent from free-form text (scope/date/intent classification). - - Example anti-pattern: `_infer_explicit_constraint_scope`-style keyword scans. -- Use multilingual structured LLM outputs instead: - - `nlu.py` (`PlannedDateResult`, `ConstraintInterpretation`). -- Deterministic parsing is only acceptable for explicitly structured values (ISO timestamps, Slack IDs, known schema fields). - Never post-process LLM prose with phrase/substring/regex filters to drive behavior or suppress content. If behavior needs control, put it in typed schema fields and state transitions. - -## LLM-Facing Models (tb_models.py, tb_ops.py) - -- `TBEvent` / `TBPlan` are the **sole LLM-facing models** for timebox generation. -- `CalendarEvent` (SQLModel) stays for DB persistence + Slack display; never pass it to an LLM. -- All event types use the compact `ET` enum (`M`, `C`, `DW`, `SW`, `PR`, `H`, `R`, `BU`, `BG`). -- Timing is a discriminated union on field `a`: `ap` (after_previous), `bn` (before_next), `fs` (fixed_start), `fw` (fixed_window). -- `TBPatch` uses typed domain ops (`ae`, `re`, `ue`, `me`, `ra`) β€” never generic JSON Patch. -- `apply_tb_ops()` is the deterministic applicator; the LLM never directly mutates state. - -## Sync Engine (sync_engine.py, submitter.py) - -- Uses DeepDiff for semantic change detection (summary, start, end, description, colorId). -- Only mutates **agent-owned events** (identified by `fftb*` event ID prefix). -- Foreign calendar events are read-only FixedWindow constraints. -- Every remote op is logged in a `SyncTransaction` with `before_payload` for undo. -- Sync flow: `fetch_remote -> plan_sync(R, D) -> execute_sync -> log transaction`. -- Undo flow: `load transaction -> apply compensating ops in reverse`. -- `CalendarSubmitter` wraps the sync engine for coordinator use (`submit_plan()`, `undo_last()`). - -## Patcher (patching.py) - -- Uses AutoGen `AssistantAgent` with **schema-in-system-prompt** pattern. -- `TBPatch.model_json_schema()` is injected into the system prompt; the LLM returns raw JSON text. -- `_extract_patch()` strips markdown fences and parses the JSON. -- `output_content_type=TBPatch` is intentionally **NOT** used because `oneOf` from Pydantic discriminated unions breaks both OpenAI `response_format` and OpenRouter structured output on the hosts this was measured on. -- **No trustcall** in the patching path. -- Patcher takes current `TBPlan` + user message + constraints -> returns `TBPatch`. -- `apply_tb_ops()` applies the patch deterministically. - -## Background Work - -- Local constraint extraction + persistence should run in background tasks. -- Durable (Notion) preference upserts should be fire-and-forget with dedupe + timeout. -- Durable semantic dedupe must batch candidate retrieval/matching; avoid per-constraint equivalent lookups. -- Durable constraint reads should run in the background and be merged with session-scoped constraints. -- Use a separate LLM client for background extraction/intent so it cannot block stage responses. -- MCP tool names are sanitized to OpenAI-safe versions (e.g., `constraint_query_constraints`). -- Only await pending background tasks if a downstream step strictly needs them (use short timeouts). -- If skeleton drafting times out, fall back to a minimal timebox so the flow keeps moving. -- Calendar meetings are treated as immovables (fixed start/end) and must be included before gap-filling. - -## Stage Parallelism - -- Stage 0: background-kick calendar prefetch + constraint retrieval (existing). -- Stage 2: **pre-generate skeleton** in background (assumes user proceeds) using immovables + constraints + inputs-so-far. -- Stage 3: use pre-generated skeleton if available; else draft synchronously and present a markdown overview. -- Stage 4: LLM -> `TBPatch` -> `apply_tb_ops()` -> sync current `TBPlan` to calendar. -- Stage 5: review summary + optional undo follow-up (no additional submit-confirm gate). -- Slack stage controls are deterministic and click-driven: - - default controls are `Back`/`Redo`/`Cancel`, with `Proceed` shown only when the current stage is ready and there is no pending local Refine undo snapshot. - - after a Stage 4 local update is applied, the control row must swap `Proceed` for `Undo last update` (wired through the existing `Redo` action path) so the user can immediately revert without an extra advance click. - - readiness is enforced server-side when `Proceed` is clicked. - -## Stage 3/4 Contract (Hard Constraint) - -- Stage 3 is **presentation-only** for users: - - Output must be markdown overview text (rendered through Slack `markdown` block). - - Stage 3 must not fail on `Timebox` materialization/validation. - - Stage 3 may prepare/carry a draft `TBPlan` for Stage 4, but it must not require a fully validated `Timebox`. -- Stage 4 is the first stage allowed to materialize/validate `Timebox`: - - `Timebox` objects must come from the patch loop validator path (not one-off conversion outside retry loop). - - Validation failures must be fed back into patch retry context so the LLM can repair. - - Keep retry-driven repair bounded but robust (default max attempts is 5 unless explicitly overridden). -- Do not add hardcoded event-shape "fixup" shortcuts that bypass patch-loop repair logic. - -## UX Status - -- When background work is queued, include a short, friendly status note in stage responses. -- Status notes should reassure the user they can continue without waiting. +- This rule outlives any one module: it applied to the deleted `nlu.py` and applies equally to the elicitation judges and any future module in this directory. ## Task Sources -- If TickTick MCP is configured (`TICKTICK_MCP_URL`), stage agents may use TickTick tools to pull tasks. -- Treat task fetch failures as non-blocking; continue the flow with user-provided inputs. +- TickTick MCP task-fetching (`TICKTICK_MCP_URL`) has moved to `fateforger/agents/tasks/` (see `agents/tasks/README.md`); it is not this directory's concern any more. ## Implementation Ticket -- **Read `TICKET_SYNC_ENGINE.md` (repo root) before making changes to this module.** -- Follow the phased checklist; update checkboxes as items complete. +- `TICKET_SYNC_ENGINE.md` (repo root) is the retired sync engine's implementation ticket -- historical, not a live checklist for this directory. diff --git a/src/fateforger/agents/timeboxing/README.md b/src/fateforger/agents/timeboxing/README.md index 99af840d..d7db848e 100644 --- a/src/fateforger/agents/timeboxing/README.md +++ b/src/fateforger/agents/timeboxing/README.md @@ -2,93 +2,75 @@ Stage-gated timeboxing workflow that builds daily schedules via conversational refinement and syncs to Google Calendar. +> **Retired (2026-09-09).** The coordinator this file describes +> (`agent.py`'s `PlanningCoordinator`), its `GraphFlow` orchestration +> (`flow_graph.py`, `nodes/`), the sync engine (`sync_engine.py`, +> `calendar_reconciliation.py`, `submitter.py`), the schema-in-prompt patcher +> (`patching.py`), the LLM-facing plan/patch models (`tb_models.py`, +> `tb_ops.py`, `timebox.py`), the Notion-backed constraint plumbing +> (`constraint_retriever.py`, `constraint_search_tool.py`, +> `notion_constraint_extractor.py`), and the calendar MCP client +> (`McpCalendarClient`, formerly in `mcp_clients.py`) are all deleted β€” +> `refactor: retire TimeboxingFlowAgent and the 34 modules only it reached` +> (commit `67489cd`). Slack may still carry buttons from cards that flow +> posted; `slack_bot/retired_cards.py` rewrites a press to say the flow is +> retired instead of dispatching here. What remains in this directory is a +> different, newer path: the Stage 1 elicitation loop (`elicitation.py`, +> `elicitation_judges.py`, driven from `slack_bot/timeboxing_host.py`) and +> the durable constraint-memory backends (`graphiti_constraint_memory.py`, +> `constraint_record_memory.py`, `kg_constraint_client.py`, +> `durable_constraint_store.py`, and `mcp_clients.py`'s surviving +> `ConstraintMemoryClient`), which are now read by +> `fateforger.agents.tasks.defaults_memory` (tasks' defaults memory) and by +> `runtime.py`'s startup checks β€” not by any timeboxing coordinator, which no +> longer exists. The Status table, File Index, and Architecture sections +> below have been corrected in place, not kept as a historical record like +> the two root calendar docs, because most of this file described modules +> that no longer exist. + ## Status | Subsystem | Status | Tests | Confirmed | |-----------|--------|-------|-----------| -| Domain models (tb_models, tb_ops) | Implemented, Tested | 62 unit | 2025-07-22 | -| Sync engine (sync_engine, submitter, calendar_reconciliation) | Implemented, Tested | 37 + 10 unit | 2026-02-14 (duplicate-prevention reconciliation) | -| Patching (schema-in-prompt) | Implemented, Tested | 14 unit | 2025-07-22 (live LLM) | -| GraphFlow orchestration | Implemented, Documented | graphflow state machine tests | β€” | -| Skeleton pre-generation (AC1) | Implemented, Tested | `test_timeboxing_skeleton_pre_generation.py` | β€” | -| Calendar sync + undo controls | Implemented, Tested (submit-time baseline refresh + deterministic reconciliation summary in Stage 5) | `test_timeboxing_submit_flow.py`, `test_slack_timebox_buttons.py` | 2026-03-07 | -| Durable profile/date-span constraint auto-upsert + Stage 1 prefetch wait | Implemented, Tested | `test_timeboxing_durable_constraints.py`, `test_timeboxing_constraint_memory_client_tool_name.py` | β€” | -| Graphiti durable memory cutover (Neo4j-backed MCP, no Mem0/file fallback) | Implemented, Tested | `test_graphiti_constraint_memory.py`, `test_settings_mcp_endpoints.py`, `test_runtime_mcp_startup_checks.py`, `test_timeboxing_memory_backend_selection.py` | 2026-03-10 | -| Constraint-memory MCP payload decoding hardening | Implemented, Tested | `test_timeboxing_constraint_memory_client_tool_name.py` | β€” | +| Domain models (tb_models, tb_ops) | Retired 2026-09-09 β€” `tb_models.py`/`tb_ops.py` deleted with the coordinator | β€” | `67489cd` | +| Sync engine (sync_engine, submitter, calendar_reconciliation) | Retired 2026-09-09 β€” deleted with the coordinator | β€” | `67489cd` | +| Patching (schema-in-prompt) | Retired 2026-09-09 β€” `patching.py` deleted with the coordinator | β€” | `67489cd` | +| GraphFlow orchestration | Retired 2026-09-09 β€” `flow_graph.py` and the `nodes/` package deleted with the coordinator | β€” | `67489cd` | +| Skeleton pre-generation (AC1) | Retired 2026-09-09 β€” deleted with the coordinator; its test (`test_timeboxing_skeleton_pre_generation.py`) went with it | β€” | `67489cd` | +| Calendar sync + undo controls | Retired at the Slack layer (2026-09-09): pressing a Stage 5 card button rewrites it with a "this flow is retired" message instead of dispatching to the agent -- see `retired_cards.py`. The agent code itself (`agent.py`) is deleted too, in the same day's follow-up retirement commit. | `test_retired_cards.py` | `67489cd` | +| Durable profile/date-span constraint auto-upsert + Stage 1 prefetch wait | Retired 2026-09-09 β€” the write path (`agent.py`'s `_upsert_constraints_to_durable_store`) is deleted; its test (`test_timeboxing_durable_constraints.py`) went with it. `mcp_clients.py`'s payload decoding still has its own test (row below). | β€” | `67489cd` | +| Graphiti durable memory cutover (Neo4j-backed MCP, no Mem0/file fallback) | Implemented, Tested β€” read via `settings.timeboxing_memory_backend`, now by `runtime.py`'s startup checks and `fateforger.agents.tasks.defaults_memory` (tasks' defaults memory), not by the deleted coordinator | `test_graphiti_constraint_memory.py`, `test_settings_mcp_endpoints.py`, `test_runtime_mcp_startup_checks.py` | 2026-03-10 | +| Constraint-memory MCP payload decoding hardening | Implemented, Tested β€” `mcp_clients.py`'s `ConstraintMemoryClient`, read by tasks' defaults memory | `test_timeboxing_constraint_memory_client_tool_name.py` | β€” | | Stage 1 elicitation loop (concern-floor coverage matrix, three judges, arithmetic gate) | Implemented, Tested (see [Stage 1 Elicitation](#stage-1-elicitation)) | `test_elicitation_gate.py`, `test_elicitation_judges.py`, `test_elicitation_composes.py`, `tests/evals/test_stage1_elicitation.py` | 2026-09-06 | -| Stage 3 markdown-first skeleton overview | Implemented, Tested | `test_timeboxing_skeleton_draft_contract.py` | β€” | -| Stage 4 advisory quality facts (0-4) | Implemented, Tested | `test_phase4_rewiring.py` | β€” | -| Deterministic stage action buttons | Implemented, Tested | `test_timeboxing_stage_actions.py`, `test_slack_timebox_stage_buttons.py` | β€” | -| Structured-output strict tool contract | Implemented, Tested | `test_timeboxing_constraint_search_tool_strict.py`, `test_timeboxing_flow.py` | β€” | +| Stage 3 markdown-first skeleton overview | Retired 2026-09-09 β€” deleted with the coordinator; its test (`test_timeboxing_skeleton_draft_contract.py`) went with it | β€” | `67489cd` | +| Stage 4 advisory quality facts (0-4) | Retired 2026-09-09 β€” deleted with the coordinator; its test (`test_phase4_rewiring.py`) went with it | β€” | `67489cd` | +| Deterministic stage action buttons | Retired at the Slack layer (2026-09-09), same as the row above -- see `retired_cards.py` | `test_retired_cards.py` | β€” | +| Structured-output strict tool contract | Retired 2026-09-09 β€” `constraint_search_tool.py` and the `TimeboxingFlow` it validated are deleted; both tests (`test_timeboxing_constraint_search_tool_strict.py`, `test_timeboxing_flow.py`) went with it | β€” | `67489cd` | ## File Index -### Orchestration - -| File | Responsibility | -|------|---------------| -| `agent.py` | `PlanningCoordinator`: owns Session, routes Slack messages, runs background tasks, manages stage transitions. Entry points: `on_start()`, `on_commit_date()`, `on_user_reply()`. | -| `flow_graph.py` | `build_timeboxing_graphflow()`: constructs the AutoGen GraphFlow DAG. Single source of truth for stage transitions and edge conditions. | -| `stage_gating.py` | `TimeboxingStage` enum, `StageGateOutput` model, LLM prompt templates for each stage gate. | -| `contracts.py` | Typed stage-context contracts (`SkeletonContext`, `ConstraintContext`, etc.): what each stage receives as input. | -| `constants.py` | Orchestration timeouts, limits, and fallback values. No magic numbers. | - -### Domain Models (LLM-Facing) - -| File | Responsibility | -|------|---------------| -| `tb_models.py` | `ET` (event type enum), `TBEvent`, `TBPlan`, `Timing` union (`AfterPrev`, `BeforeNext`, `FixedStart`, `FixedWindow`), `_ET_COLOR_MAP`. Calendar-native, sync-friendly. | -| `tb_ops.py` | `TBPatch`, `TBOp` union (`AddEvents`, `RemoveEvent`, `UpdateEvent`, `MoveEvent`, `ReplaceAll`), `apply_tb_ops()`. Pure-function ops engine: deterministic plan mutation. | -| `timebox.py` | Legacy `Timebox` schema + `schedule_and_validate()`. Conversion: `timebox_to_tb_plan()`, `tb_plan_to_timebox()`. Kept for backward compat with Stage 3 drafting and Slack display. | - -### Calendar Sync - -| File | Responsibility | -|------|---------------| -| `sync_engine.py` | `plan_sync()`, `execute_sync()`, `undo_sync()`, `gcal_response_to_tb_plan()`. Deterministic, incremental, reversible diff-and-apply via MCP. Uses reconciliation-first matching and DeepDiff only for matched update decisions. | -| `calendar_reconciliation.py` | Deterministic desired-vs-remote matching (`id -> canonical -> fuzzy`) and op-bucket planning (`create/update/delete/noop/skip`). | -| `submitter.py` | `CalendarSubmitter`: high-level `submit_plan()`, `undo_last()`, and `undo_transaction()` over the sync engine. | -| `mcp_clients.py` | `McpCalendarClient` (list/create/update/delete events via MCP), `McpConstraintMemoryClient` (Notion constraint MCP). Internal to coordinator. | -| `graphiti_constraint_memory.py` | Active Graphiti durable-memory adapter. Uses Graphiti MCP in runtime and requires Neo4j-backed deployment config. | -| `constraint_record_memory.py` | Backend-neutral durable constraint serialization/query/update contract used by the active Graphiti adapter. | - -### LLM Patching - -| File | Responsibility | -|------|---------------| -| `patching.py` | `TimeboxPatcher`: sends `TBPlan` + user feedback to the pinned pro model (`OPENROUTER_DEFAULT_MODEL_PRO`, DeepSeek V4 Pro `:nitro`) via `AssistantAgent`. Injects `TBPatch` JSON schema into system prompt (not `output_content_type`, which breaks on `oneOf`). `_extract_patch()` strips markdown fences. | - -### Prompt Engineering - -| File | Responsibility | -|------|---------------| -| `skeleton_draft_system_prompt.j2` | Jinja2 template for skeleton drafting (consumes TOON tables). | -| `prompt_rendering.py` | `render_skeleton_draft_system_prompt()`: Jinja renderer. | -| `planning_policy.py` | Shared Stage 3/4 planning policy text + quality rubric constants. | -| `toon_views.py` | Timeboxing-specific TOON table views (minimal columns for events, constraints, tasks). | -| `prompts.py` | Legacy prompt strings (being migrated to `stage_gating.py`). | - -### NLU and Constraints - -| File | Responsibility | -|------|---------------| -| `nlu.py` | `PlannedDateResult`, `ConstraintInterpretation`: structured LLM outputs for multilingual date/scope inference. No regex/keyword matching. | -| `preferences.py` | `ConstraintStore`: SQLite-backed session constraint persistence. | -| `constraint_retriever.py` | `ConstraintRetriever`: gap-driven durable constraint fetch from Notion MCP. | -| `graphiti_constraint_memory.py` | Graphiti MCP transport for durable constraint memory (active runtime path; Neo4j-backed deployment contract). | -| `constraint_record_memory.py` | Shared durable constraint behavior used by the Graphiti adapter. | -| `constraint_search_tool.py` | Stage-gating Notion search tool (`search_constraints`) with strict FunctionTool schema for structured-output compatibility. | -| `notion_constraint_extractor.py` | **TODO(deprecate)** β€” dead code. The Notion-MCP extraction path is never reached; the live write path is `_upsert_constraints_to_durable_store`. Do not import from new code. | +Everything the coordinator owned (orchestration, domain models, calendar +sync, patching, prompt engineering, the Notion-backed NLU/constraint +plumbing, Slack routing utilities, and the `nodes/` subfolder) was deleted +2026-09-09 with `TimeboxingFlowAgent` β€” see the retirement note above for the +file list and the commit. What is left in this directory is the durable +constraint-memory backends and the Stage 1 elicitation loop, both of which +now live and are called from elsewhere (tasks' defaults memory, +`runtime.py`, and `slack_bot/timeboxing_host.py`), plus a newer, +undocumented-here artifact-led planning-session module +(`adaptive_timeboxing.py`, `session_contracts.py`, `readiness.py`, +`required_blocks.py`, `day_frame.py`, `feedback.py`). -### Utilities +### Durable Constraint Memory | File | Responsibility | |------|---------------| -| `pydantic_parsing.py` | Tolerant parsing helpers for LLM outputs and mixed payloads. | -| `messages.py` | `StartTimeboxing`, `TimeboxingUserReply`, `TimeboxingCommitDate`: typed Slack-to-agent routing messages. | -| `actions.py` | Slack action/button payload models and helpers (planning cards). | -| `state.py` | Session persistence helpers. | -| `flow.py` | Legacy flow logic (being replaced by GraphFlow). | +| `mcp_clients.py` | `ConstraintMemoryClient` (the constraint-memory MCP stdio workbench client). `McpCalendarClient` used to live here too; it was deleted with the coordinator. Read by `fateforger.agents.tasks.defaults_memory`, not by any timeboxing coordinator. | +| `graphiti_constraint_memory.py` | Graphiti durable-memory adapter (Graphiti MCP transport; Neo4j-backed deployment config). Read via `settings.timeboxing_memory_backend` by `runtime.py`'s startup checks and by `fateforger.agents.tasks.defaults_memory`. | +| `constraint_record_memory.py` | Backend-neutral durable constraint serialization/query/update contract used by the Graphiti adapter. | +| `kg_constraint_client.py` | Read-only client onto the standalone memory server's own store (`data/memory.db`), speaking the same `DurableConstraintStore` contract as the Graphiti adapter β€” the replacement for the Notion-backed `constraint_mcp` reads that used to 404. | +| `durable_constraint_store.py` | The `DurableConstraintStore` protocol: one small backend-neutral interface the concrete durable-memory clients above implement, so `runtime.py` and tasks' defaults memory stay backend-neutral. | +| `preferences.py` | `Constraint`/`ConstraintBase` models and their enums (necessity, status, source, scope). The session-store persistence class that once lived here (`ConstraintStore`) had no writer left after the legacy agent retired (2026-09-09) and was removed. | ### Stage 1 Elicitation @@ -100,106 +82,58 @@ Stage-gated timeboxing workflow that builds daily schedules via conversational r Design: `docs/superpowers/specs/2026-09-05-stage1-elicitation-loop-design.md`. Measurements: `docs/superpowers/research/2026-09-06-stage1-loop-evals.md`. -### Subfolders - -| Folder | Responsibility | -|--------|---------------| -| `nodes/` | GraphFlow node agents (TurnInit, Decision, Transition, Stage nodes, Presenter). See `nodes/README.md`. | - ## Architecture -### Coordinator + Stage Agents - -- **Coordinator** (`agent.py`): owns Session state, merges facts/constraints, runs background tool work (calendar, Notion), and decides which stage runs next. -- **Stage agents** (in `nodes/`): pure functions over typed JSON input returning typed JSON output. No direct tool IO. -- **GraphFlow** (`flow_graph.py`): runs the stage machine as a directed graph; transitions are testable and explicit. - -### Stage Pipeline - -``` -Stage 0: Date Confirmation (Slack buttons) - background: calendar prefetch + Notion constraint retrieval (with short await before first Stage 1 render) -Stage 1: Constraints -> elicitation loop (elicitation.py, elicitation_judges.py): three judges - (PlacementJudge, CoverageJudge, ProbeJudge) fill a CoverageMatrix over the concern floor each - turn; stage1_gate asks the top open cell and proposes to close only once every cell is - covered, not applicable, or already answered -- locking the day does not, by itself, close it -Stage 2: CaptureInputs -> StageGateOutput (input_facts) -Stage 3: Skeleton -> pre-generated draft if available, else synchronous draft -> markdown overview (presentation-first) + carry-forward seed `TBPlan` (no Stage 3 patch loop) -Stage 4: Refine -> prompt-guided tool orchestration (`timebox_patch_and_sync` primary, `memory_extract_and_upsert` optional background) -> advisory quality facts (0-4) -> sync to Google Calendar with explicit changed/unchanged reporting -Stage 5: ReviewCommit -> final summary; user corrections route back to Stage 4 Refine in the same turn -Undo action: undo latest sync transaction via session-backed state -> return to Refine -Each stage response also includes deterministic Slack actions (Back/Redo/Cancel, plus Proceed when ready). After a local Refine update is applied, the control row swaps Proceed for "Undo last update" so users can immediately revert without an extra stage-advance click. -``` - -### Session State - -Session dataclass lives in `agent.py`. Core fields: - -| Field | Purpose | -|-------|---------| -| `thread_ts`, `channel_id`, `user_id` | Slack anchors | -| `frame_facts`, `input_facts` | Accumulated LLM outputs per stage | -| `timebox` | Legacy Timebox (Stage 3+) | -| `tb_plan` | Current TBPlan, sync-engine model (prepared by Stage 2 draft and/or Stage 4 preflight) | -| `base_snapshot` | Remote-baseline snapshot for diff-based sync (prepared in Stage 4 preflight) | -| `event_id_map` | Dict mapping event key to GCal event ID | -| `prefetched_remote_snapshots_by_date` | Rich remote baseline snapshots (from list-events) keyed by date | -| `remote_event_ids_by_index` | Ordered remote event IDs aligned with `base_snapshot.resolve_times()` | -| `pre_generated_skeleton` | Background Stage 2 draft consumed by Stage 3 when fresh | -| `skeleton_overview_markdown` | Stage 3 markdown summary rendered to Slack | -| `last_quality_level`, `last_quality_label`, `last_quality_next_step` | Latest Refine quality snapshot carried into next patch context | -| `last_sync_transaction` | Session-backed transaction used for deterministic undo | -| `last_refine_undo_tb_plan`, `last_refine_undo_timebox` | Session-backed local draft snapshot used by "Undo last update" in Refine | -| `active_constraints` | Merged constraint state | -| `stage` | Current TimeboxingStage enum | -| `graphflow` | Per-session GraphFlow instance | - -### Model Hierarchy - -``` -LLM-facing: TBEvent -> TBPlan -> TBPatch -> apply_tb_ops() -Sync engine: TBPlan -> plan_sync() -> SyncOp[] -> execute_sync() -Calendar MCP: SyncOp -> create-event / update-event / delete-event -Persistence: CalendarEvent (SQLModel) for DB + Slack display -Conversion: timebox_to_tb_plan() / tb_plan_to_timebox() -``` - -### Event Identity - -- Agent-created events get deterministic base32hex IDs: `fftb` + SHA1(date|name|start|index). -- `fftb*` prefix = owned, eligible for update/delete. -- No prefix = foreign (user calendar), read-only FixedWindow constraints. - -### Patching (Schema-in-Prompt) - -`output_content_type=TBPatch` is intentionally NOT used because OpenAI `response_format` rejects `oneOf` from Pydantic discriminated unions and OpenRouter structured output hung on complex schemas on the hosts this was measured on. Instead: inject `TBPatch.model_json_schema()` into the system prompt and parse the raw JSON text response. - -Runtime guard: `TimeboxPatcher.apply_patch(...)` is Refine-only (`stage='Refine'`) and rejects any non-Refine invocation. - -### TOON Prompt Injection - -List-shaped data (constraints, tasks, immovables, events) uses TOON tabular format, not JSON arrays. Encoder: `src/fateforger/llm/toon.py`. +The Coordinator + Stage Agents / GraphFlow / Stage Pipeline / Session State / +Model Hierarchy / Event Identity / Patching (Schema-in-Prompt) / TOON Prompt +Injection sections that used to sit here described `agent.py`'s +`PlanningCoordinator`, `flow_graph.py`'s GraphFlow DAG, and the `nodes/` +stage agents β€” all deleted 2026-09-09 (`67489cd`, see the retirement note at +the top of this file). Slack's `/timebox` slash command and the button flow +it drove now dead-end at `retired_cards.py` rather than reaching an agent in +this directory; `src/fateforger/slack_bot/planning.py` has its own, +unrelated `PlanningCoordinator` for the Planning/Scheduling UI card flow +(see `slack_bot/README.md`'s "Proposal Object Interaction Contract"), and is +not a continuation of this module's coordinator. What that architecture +looked like is in git history at `67489cd^`, not repeated here as a +historical record, because the two root calendar docs already show that +pattern and a second copy would invite editing a description of dead code +instead of reading the commit. + +What survives here is the Stage 1 elicitation loop (`elicitation.py`, +`elicitation_judges.py` β€” see [Stage 1 Elicitation](#stage-1-elicitation) +above and the design/measurement docs it links), the durable +constraint-memory backends (see the File Index above), and a newer +artifact-led planning-session module (`adaptive_timeboxing.py`, +`session_contracts.py`, `readiness.py`, `required_blocks.py`, +`day_frame.py`, `feedback.py`) that this README does not yet document in +architectural terms. ## Related Files (Outside This Folder) | File | Role | |------|------| -| `src/fateforger/slack_bot/handlers.py` | Routes Slack events to coordinator | -| `src/fateforger/slack_bot/timeboxing_commit.py` | Stage 0 Slack UI (day picker + confirm button) | -| `src/fateforger/llm/toon.py` | TOON tabular encoder | -| `TICKET_SYNC_ENGINE.md` | Implementation ticket (repo root) | -| `notebooks/phase5_integration_test.ipynb` | Live MCP + LLM integration tests | +| `src/fateforger/slack_bot/handlers.py` | Central Slack event/action router. No longer routes to a coordinator in this directory (see the Architecture note above); still owns `/timebox` and dispatches retired-card presses to `retired_cards.py`. | +| `src/fateforger/slack_bot/timeboxing_commit.py` | Stage 0 Slack UI (day picker + confirm button). Its target coordinator is deleted; see `slack_bot/README.md`'s Timeboxing UI file index. | +| `TICKET_SYNC_ENGINE.md` | Implementation ticket (repo root) for the now-deleted sync engine β€” historical. | +| `notebooks/phase5_integration_test.ipynb` | Live MCP + LLM integration tests written against the deleted coordinator β€” not verified against current code. | + +`src/fateforger/llm/toon.py`, the TOON tabular encoder this section used to +cite, is deleted too; no surviving module in this directory imports it. ## How to Run Tests ```bash -# Sync engine suite (115 tests) -poetry run pytest tests/unit/test_tb_models.py tests/unit/test_tb_ops.py \ - tests/unit/test_sync_engine.py tests/unit/test_phase4_rewiring.py \ - tests/unit/test_patching.py -v - -# GraphFlow state machine -poetry run pytest tests/unit/test_timeboxing_graphflow_state_machine.py -v +# Durable constraint-memory backends +poetry run pytest tests/unit/constraints/test_graphiti_constraint_memory.py \ + tests/unit/constraints/test_timeboxing_constraint_memory_client_tool_name.py \ + tests/unit/core/test_settings_mcp_endpoints.py \ + tests/unit/core/test_runtime_mcp_startup_checks.py -v + +# Stage 1 elicitation loop +poetry run pytest tests/unit/timeboxing/test_elicitation_gate.py \ + tests/unit/timeboxing/test_elicitation_judges.py \ + tests/unit/timeboxing/test_elicitation_composes.py -v # All timeboxing-related tests poetry run pytest tests/unit/ -k timeboxing -v diff --git a/src/fateforger/agents/timeboxing/actions.py b/src/fateforger/agents/timeboxing/actions.py deleted file mode 100644 index 9eb16650..00000000 --- a/src/fateforger/agents/timeboxing/actions.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Timebox action summaries for agent memory.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal, Optional - - -@dataclass(frozen=True) -class TimeboxAction: - kind: Literal["insert", "delete", "move", "update"] - event_key: str - summary: str - from_time: Optional[str] = None - to_time: Optional[str] = None - reason: Optional[str] = None - - -__all__ = ["TimeboxAction"] diff --git a/src/fateforger/agents/timeboxing/adaptive_timeboxing.py b/src/fateforger/agents/timeboxing/adaptive_timeboxing.py index 851afc17..7b3f502c 100644 --- a/src/fateforger/agents/timeboxing/adaptive_timeboxing.py +++ b/src/fateforger/agents/timeboxing/adaptive_timeboxing.py @@ -68,6 +68,11 @@ logger = logging.getLogger(__name__) +#: How many times in a row the planner may ask for another turn before the +#: session says it is looping. Legacy's `_REFINE_NO_CHANGE_LIMIT` (9eb333e): +#: a real session once ran nine identical passes before anyone noticed. +MAX_CONSECUTIVE_CONTINUATIONS = 3 + class TurnRequest(BaseModel): """One idempotent, revision-aware planning-session interaction.""" @@ -1373,7 +1378,7 @@ def _apply_planning_result( # could not finish inside one. return ( self._continue_later(snapshot, assumptions), - self._another_turn(result), + self._another_turn(snapshot, result), ) return snapshot, TurnFailed( code="missing_required_artifact", @@ -1440,22 +1445,43 @@ def _apply_planning_result( # It produced something *and* wants to keep going. The artifact is # kept -- it is real work -- but it is not offered for approval, # because the planner has just said it is not finished. - return updated, self._another_turn(result) + return updated, self._another_turn(snapshot, result) return updated, AwaitingApproval(artifact=artifact) - def _another_turn(self, result: PlanningResult) -> NeedsAnotherTurn: - """Log it and type it. + def _another_turn( + self, snapshot: PlanningSessionSnapshot, result: PlanningResult + ) -> NeedsAnotherTurn | TurnFailed: + """Let the planner continue, until continuing is all it does. - Logged at warning because a planner that asks every turn is a bug, and - a silent continuation is indistinguishable from slow progress -- which - is how a loop would hide. + Logged at warning because a planner that asks every turn is a bug, + and a silent continuation is indistinguishable from slow progress -- + which is how a loop would hide. The streak is the run of + ``needs_another_turn`` outcomes at the tail of the session's handled + interactions; this turn would be one more. """ assert result.continuation is not None - logger.warning( - "planner asked for another turn reason=%s", result.continuation.reason - ) - return NeedsAnotherTurn(reason=result.continuation.reason) + reason = result.continuation.reason + streak = 1 + for handled in reversed(snapshot.handled_interactions): + if handled.outcome_kind != "needs_another_turn": + break + streak += 1 + if streak >= MAX_CONSECUTIVE_CONTINUATIONS: + logger.warning( + "planner asked for another turn %d times in a row; failing the turn reason=%s", + streak, + reason, + ) + return TurnFailed( + code="no_progress", + message=( + f"The planner asked for another turn {streak} times in a row " + f"without finishing. Last reason: {reason}" + ), + ) + logger.warning("planner asked for another turn reason=%s", reason) + return NeedsAnotherTurn(reason=reason) def _continue_later( self, diff --git a/src/fateforger/agents/timeboxing/agent.py b/src/fateforger/agents/timeboxing/agent.py deleted file mode 100644 index 4ec5a17d..00000000 --- a/src/fateforger/agents/timeboxing/agent.py +++ /dev/null @@ -1,8827 +0,0 @@ -"""Coordinator agent that runs a stage-gated timeboxing flow.""" - -from __future__ import annotations - -import asyncio -import hashlib -import json -import logging -import os -import re -import sys -from dataclasses import dataclass, field -from datetime import date, datetime, time, timedelta, timezone -from enum import Enum -from functools import wraps -from pathlib import Path -from time import perf_counter -from typing import ( - Any, - Callable, - Dict, - Iterable, - List, - Literal, - ParamSpec, - Type, - TypeVar, -) -from zoneinfo import ZoneInfo - -from autogen_agentchat.agents import AssistantAgent -from autogen_agentchat.messages import TextMessage -from autogen_agentchat.teams import GraphFlow -from autogen_core import ( - CancellationToken, - DefaultTopicId, - MessageContext, - RoutedAgent, - message_handler, -) -from autogen_core.tools import FunctionTool -from dateutil import parser as date_parser -from pydantic import BaseModel -from pydantic import Field as PydanticField -from pydantic import TypeAdapter, ValidationError, model_validator -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine - -from fateforger.agents.schedular.models.calendar import CalendarEvent, EventType -from fateforger.agents.timeboxing.constraint_search_tool import ( - ConstraintSearchQuery, - search_constraints, -) -from fateforger.core.config import settings -from fateforger.core.logging_config import observe_stage_duration -from fateforger.debug.diag import with_timeout -from fateforger.debug.log_index import append_index_entry -from fateforger.haunt.timeboxing_activity import timeboxing_activity -from fateforger.llm import ( - assert_strict_tools_for_structured_output, - build_autogen_chat_client, -) -from fateforger.llm.toon import toon_encode -from fateforger.sync_core import ( - ReconciliationSummary, - SubmitBaselineGuard, - evaluate_submit_baseline_guard, - summarize_reconciliation, -) -from fateforger.slack_bot.constraint_review import ( - CONSTRAINT_REVIEW_ALL_ACTION_ID, - CONSTRAINT_ROW_REVIEW_ACTION_ID, - build_constraint_review_all_action_block, - build_constraint_row_blocks, - encode_metadata, -) -from fateforger.slack_bot.messages import SlackBlockMessage, SlackThreadStateMessage -from fateforger.slack_bot.timeboxing_commit import build_timebox_commit_prompt_message -from fateforger.slack_bot.timeboxing_stage_actions import build_stage_actions_block -from fateforger.slack_bot.timeboxing_submit import ( - build_markdown_block, - build_review_submit_actions_block, - build_text_section_block, - build_undo_submit_actions_block, -) -from fateforger.tools.ticktick_mcp import TickTickMcpClient, get_ticktick_mcp_url - -from .actions import TimeboxAction -from .constants import TIMEBOXING_FALLBACK, TIMEBOXING_LIMITS, TIMEBOXING_TIMEOUTS -from .constraint_memory_component import ConstraintPlanningMemory -from .constraint_reconciliation import reconcile_constraint_rows -from .constraint_retriever import STARTUP_PREFETCH_TAG, ConstraintRetriever -from .contracts import ( - BlockPlan, - CaptureInputsContext, - CollectConstraintsContext, - DailyOneThing, - Immovable, - SkeletonContext, - SleepTarget, - TaskCandidate, - WorkWindow, -) -from .durable_constraint_store import ( - DurableConstraintStore, - build_durable_constraint_store, -) -from .flow_graph import build_timeboxing_graphflow -from .mcp_clients import ConstraintMemoryClient, McpCalendarClient -from .graphiti_constraint_memory import build_graphiti_client_from_settings -from .messages import ( - StartTimeboxing, - TimeboxingCancelSubmit, - TimeboxingCommitDate, - TimeboxingConfirmSubmit, - TimeboxingFinalResult, - TimeboxingStageAction, - TimeboxingUndoSubmit, - TimeboxingUpdate, - TimeboxingUserReply, -) -from .nlu import ( - ConstraintInterpretation, - MemoryReviewDecision, - PlannedDateResult, - build_constraint_interpreter, - build_memory_review_router, - build_planned_date_interpreter, -) - -# TODO(deprecate): NotionConstraintExtractor and the Notion constraint MCP path are -# dead code. The live write path is _upsert_constraints_to_durable_store. -# Remove this import together with _ensure_constraint_mcp_tools below. -from .notion_constraint_extractor import NotionConstraintExtractor -from .patching import TimeboxPatcher -from .planning_aspects import ConstraintAspectClassification -from .planning_policy import QUALITY_RUBRIC_PROMPT -from .preferences import ( - Constraint, - ConstraintBase, - ConstraintBatch, - ConstraintDayOfWeek, - ConstraintNecessity, - ConstraintScope, - ConstraintSource, - ConstraintStatus, - ConstraintStore, - ensure_constraint_schema, -) -from .prompt_rendering import render_skeleton_draft_system_prompt -from .pydantic_parsing import parse_chat_content, parse_model_list, parse_model_optional -from .scheduler_prefetch_capability import SchedulerPrefetchCapability -from .stage_gating import ( - CAPTURE_INPUTS_PROMPT, - COLLECT_CONSTRAINTS_PROMPT, - DECISION_PROMPT, - REVIEW_COMMIT_PROMPT, - TIMEBOX_SUMMARY_PROMPT, - ConstraintsSection, - FreeformSection, - NextStepsSection, - SessionMessage, - StageDecision, - StageGateOutput, - TimeboxingStage, -) -from .submitter import CalendarSubmitter -from .sync_engine import ( - FFTB_PREFIX, - SyncTransaction, - gcal_response_to_tb_plan_with_identity, - plan_sync, -) -from .task_marshalling_capability import TaskMarshallingCapability -from .tb_models import TBEvent, TBPlan -from .timebox import Timebox, tb_plan_to_timebox, timebox_to_tb_plan -from .tool_result_models import InteractionMode, MemoryConstraintItem, MemoryToolResult -from .tool_result_presenter import InteractionContext, present_memory_tool_result -from .toon_views import ( - constraints_rows, - immovables_rows, - tasks_rows, - timebox_events_rows, -) -from tmbx.journal.instrument import JournalingPatcher, JournalingSubmitter -from tmbx.journal.store import JournalStore, journal_sessionmaker - -logger = logging.getLogger(__name__) -TEnum = TypeVar("TEnum", bound=Enum) -P = ParamSpec("P") -R = TypeVar("R") - -_JOURNAL_STORE: JournalStore | None = None - - -def _fallback_on_parse_error(default: R) -> Callable[[Callable[P, R]], Callable[P, R]]: - """Return a decorator that falls back when pydantic/date parsing fails.""" - - def _decorator(func: Callable[P, R]) -> Callable[P, R]: - @wraps(func) - def _wrapped(*args: P.args, **kwargs: P.kwargs) -> R: - try: - return func(*args, **kwargs) - except (ValidationError, TypeError, ValueError): - return default - - return _wrapped - - return _decorator - - -def _build_journal_store() -> JournalStore | None: - """Open the patch journal, or return ``None`` if unavailable. - - Synchronous by construction. This runs from ``__init__`` while the Slack - bot's event loop is already running, so it must never block that loop - with a synchronous wait for a coroutine β€” such a call raises inside a - running loop, and because the failure path degrades to ``None``, the - journal would be silently disabled in production while every test - passed. - - ``journal_sessionmaker`` only builds a lazily-connecting engine. The schema - is created out of band by ``tmbx-init-journal``; if it is missing, the - first append fails, is logged by the decorator's guard, and planning - continues. - """ - global _JOURNAL_STORE - if _JOURNAL_STORE is not None: - return _JOURNAL_STORE - try: - _JOURNAL_STORE = JournalStore(journal_sessionmaker()) - return _JOURNAL_STORE - except Exception: - logger.warning("patch journal unavailable; continuing unjournaled", exc_info=True) - return None - - -def _maybe_journal_patcher(patcher: Any, store: JournalStore | None) -> Any: - """Wrap the patcher when a journal is available, else pass it through.""" - if store is None: - return patcher - return JournalingPatcher(patcher, store) - - -def _maybe_journal_submitter(submitter: Any, store: JournalStore | None) -> Any: - """Wrap the submitter when a journal is available, else pass it through.""" - if store is None: - return submitter - return JournalingSubmitter(submitter, store) - - -class _ConstraintInterpretationPayload(BaseModel): - """Input payload for constraint interpretation (multilingual, structured output).""" - - text: str - is_initial: bool - planned_date: str | None = None - timezone: str | None = None - stage_id: str | None = None - - -class _ConstraintOverviewView(BaseModel): - """Typed Stage 1 overview payload rendered in presenter output.""" - - durable_applies: list[str] = PydanticField(default_factory=list) - day_specific_applies: list[str] = PydanticField(default_factory=list) - unresolved: list[str] = PydanticField(default_factory=list) - - -class _ConstraintTemplateView(BaseModel): - """Typed Stage 1 template-coverage payload rendered in presenter output.""" - - filled_fields: list[str] = PydanticField(default_factory=list) - useful_next_fields: list[str] = PydanticField(default_factory=list) - notes: str | None = None - - -class _CalendarSnapshotEvent(BaseModel): - """Strict event shape used when normalizing remote calendar snapshots.""" - - model_config = {"extra": "ignore", "populate_by_name": True} - - summary: str = PydanticField(min_length=1) - event_type: EventType = PydanticField(default=EventType.MEETING, alias="type") - start_time: time | None = PydanticField(default=None, alias="ST") - end_time: time | None = PydanticField(default=None, alias="ET") - duration: timedelta | None = PydanticField(default=None, alias="DT") - start: datetime | date | None = None - end: datetime | date | None = None - calendarId: str = "primary" - timeZone: str = "UTC" - description: str | None = None - eventId: str | None = None - - @model_validator(mode="after") - def _require_timing(self) -> "_CalendarSnapshotEvent": - if not any( - ( - self.start is not None, - self.end is not None, - self.start_time is not None, - self.end_time is not None, - self.duration is not None, - ) - ): - raise ValueError( - "calendar snapshot event requires at least one timing field" - ) - return self - - def to_calendar_event(self) -> CalendarEvent: - payload = self.model_dump(mode="python", by_alias=False, exclude_none=True) - return CalendarEvent.model_validate(payload) - - -class RefineQualityFacts(BaseModel): - """Typed Stage 4 quality payload generated by LLM summaries.""" - - quality_level: int = PydanticField(ge=0, le=4) - quality_label: Literal["Insufficient", "Minimal", "Okay", "Detailed", "Ultra"] - missing_for_next: list[str] = PydanticField(default_factory=list) - next_suggestion: str - - -# How many consecutive Stage 4 passes may produce no plan change before the -# session stops rather than trying again. Three because one is ordinary -- a day -# that already satisfies its constraints legitimately needs no patch -- and two -# in a row is plausible after a user's no-op instruction. Nine, which is what a -# real session did, is a loop. -_REFINE_NO_CHANGE_LIMIT = 3 - - -class RefineMadeNoProgress(RuntimeError): - """Stage 4 could not change the plan, repeatedly. - - Raised rather than returned so it reaches the user. The failure it replaces - was silent: Refine re-entered, produced no patch, and left the constraint - list as the only thing to render -- so each pass appended another copy until - the message crossed Slack's size limit and `chat.update` began refusing. - At that point the error channel and the progress channel were the same - message, so the session went quiet with no way to say why. - """ - - -@dataclass -class Session: - """State container for an active timeboxing run.""" - - thread_ts: str - channel_id: str - user_id: str - last_user_message: str | None = None - last_response: str | None = None - start_message: str | None = None - completed: bool = False - committed: bool = False - planned_date: str | None = None - tz_name: str = "UTC" - prefetched_immovables_by_date: Dict[str, List[Dict[str, str]]] = field( - default_factory=dict - ) - prefetched_remote_snapshots_by_date: Dict[str, TBPlan] = field(default_factory=dict) - prefetched_event_id_maps_by_date: Dict[str, Dict[str, str]] = field( - default_factory=dict - ) - prefetched_remote_event_ids_by_date: Dict[str, List[str]] = field( - default_factory=dict - ) - active_constraints: List[Constraint] = field(default_factory=list) - active_constraints_raw_count: int = 0 - active_constraints_applicable_count: int = 0 - active_constraints_selected_count: int = 0 - last_extracted_constraints_count: int = 0 - last_refine_selected_constraints_count: int = 0 - # Consecutive Stage 4 passes that produced no plan change. Refine re-entering - # with nothing to do is not progress, and left uncapped it renders the - # constraint list again on every pass -- which is what grew one message past - # Slack's limit and turned a live session into twelve minutes of silence. - consecutive_refine_no_change: int = 0 - # How many constraints the Refine selection had to leave out. The limit is - # correct -- the patcher prompt has to be bounded and ranking by necessity - # is the right way to bound it -- but dropping a rule the user stated and - # never saying so is the failure this whole system keeps rediscovering. - last_refine_dropped_constraints_count: int = 0 - durable_constraints_by_stage: Dict[str, List[Constraint]] = field( - default_factory=dict - ) - durable_constraints_loaded_stages: set[str] = field(default_factory=set) - durable_constraints_date: str | None = None - pending_durable_constraints: bool = False - pending_durable_stages: set[str] = field(default_factory=set) - durable_constraints_failed_stages: Dict[str, str] = field(default_factory=dict) - pending_calendar_prefetch: bool = False - background_updates: List[str] = field(default_factory=list) - prefetched_pending_tasks: List[TaskCandidate] = field(default_factory=list) - pending_tasks_prefetch: bool = False - timebox: Timebox | None = None - pre_generated_skeleton: Timebox | None = None - pre_generated_skeleton_plan: TBPlan | None = None - pre_generated_skeleton_markdown: str | None = None - pre_generated_skeleton_fingerprint: str | None = None - pre_generated_skeleton_task: asyncio.Task | None = None - pending_skeleton_pre_generation: bool = False - skeleton_overview_markdown: str | None = None - tb_plan: TBPlan | None = None - base_snapshot: TBPlan | None = None - event_id_map: Dict[str, str] = field(default_factory=dict) - remote_event_ids_by_index: List[str] = field(default_factory=list) - pending_submit: bool = False - queued_submit_intent: bool = False - last_sync_transaction: SyncTransaction | None = None - last_sync_event_id_map: Dict[str, str] | None = None - last_refine_undo_tb_plan: TBPlan | None = None - last_refine_undo_timebox: Timebox | None = None - pending_presenter_blocks: List[dict[str, Any]] | None = None - stage: TimeboxingStage = TimeboxingStage.COLLECT_CONSTRAINTS - frame_facts: Dict[str, Any] = field(default_factory=dict) - input_facts: Dict[str, Any] = field(default_factory=dict) - stage_ready: bool = False - stage_missing: List[str] = field(default_factory=list) - stage_question: str | None = None - suppressed_durable_uids: set[str] = field(default_factory=set) - collect_defaults_applied: List[str] = field(default_factory=list) - last_quality_level: int | None = None - last_quality_label: str | None = None - last_quality_next_step: str | None = None - constraints_prefetched: bool = False - pending_constraint_extractions: set[str] = field(default_factory=set) - last_extraction_task: asyncio.Task | None = None - graphflow: GraphFlow | None = None - reply_turn_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False) - graph_turn_lock: asyncio.Lock = field(default_factory=asyncio.Lock, repr=False) - skip_stage_execution: bool = False - force_stage_rerun: bool = False - thread_state: str | None = None - session_key: str | None = None - debug_log_path: str | None = None - graph_turn_started_at_monotonic: float | None = None - graph_turn_deadline_monotonic: float | None = None - - -@dataclass -class RefinePreflight: - """Structured Stage 4 preflight outcome.""" - - plan_issues: list[str] = field(default_factory=list) - snapshot_issues: list[str] = field(default_factory=list) - - @property - def has_plan_issues(self) -> bool: - return bool(self.plan_issues) - - @property - def has_snapshot_issues(self) -> bool: - return bool(self.snapshot_issues) - - -@dataclass -class CalendarSyncOutcome: - """Structured result for calendar sync reporting in Stage 4/5 turns.""" - - status: str - changed: bool - created: int = 0 - updated: int = 0 - deleted: int = 0 - failed: int = 0 - note: str = "" - failed_details: list[dict[str, str]] = field(default_factory=list) - - -@dataclass -class RefineToolExecutionOutcome: - """Result of prompt-guided Stage 4 tool orchestration.""" - - patch_selected: bool - memory_queued: bool - fallback_patch_used: bool - calendar: CalendarSyncOutcome - memory_selected: bool = False - memory_operations: list[str] = field(default_factory=list) - - -# TODO(deprecate): get_constraint_mcp_tools connects to the Notion constraint MCP -# server and is only used by _ensure_constraint_mcp_tools, which is itself never -# called. Remove together with NotionConstraintExtractor. -async def get_constraint_mcp_tools() -> list: - """Acquire constraint MCP tools from the Notion constraint MCP server. - - Returns the raw tool list from :func:`mcp_server_tools` for the configured - Notion constraint endpoint. Callers must handle connection errors. - """ - from autogen_ext.tools.mcp import StreamableHttpServerParams, mcp_server_tools - - from fateforger.tools.notion_mcp import get_notion_mcp_url - - params = StreamableHttpServerParams( - url=get_notion_mcp_url(), - headers={}, - timeout=10.0, - ) - return await mcp_server_tools(params) - - -def _stamp_extraction_reason(constraints: Iterable[Any], *, reason: str) -> None: - """Record which extraction pass produced each constraint. - - Distinguishes constraints extracted from what the user actually said - (``graphflow_turn``) from those extracted from machine-authored repair - text (``refine_background_memory``). The second path fires when preflight - found plan issues, so those constraints correlate with failure β€” consumers - must be able to filter them out rather than learn from them. - - First write wins: a later pass never relabels provenance. - """ - for constraint in constraints or []: - hints = getattr(constraint, "hints", None) - if hints is None: - continue - if not isinstance(hints, dict): - continue - if "extraction_reason" in hints: - continue - hints["extraction_reason"] = reason - # SQLModel JSON columns need reassignment to register as dirty. - constraint.hints = dict(hints) - - -class TimeboxingFlowAgent(RoutedAgent): - """Entry point for the GraphFlow-driven timeboxing workflow.""" - - def __init__(self, name: str) -> None: - """Initialize the timeboxing agent and supporting clients.""" - super().__init__(description=name) - self._sessions: Dict[str, Session] = {} - self._model_client = build_autogen_chat_client( - "timeboxing_agent", parallel_tool_calls=False - ) - self._constraint_model_client = build_autogen_chat_client( - "timeboxing_agent_background", parallel_tool_calls=False - ) - self._draft_model_client = build_autogen_chat_client( - "timeboxing_draft", parallel_tool_calls=False - ) - self._calendar_client: McpCalendarClient | None = None - self._constraint_memory_client: Any | None = None - self._constraint_memory_unavailable_reason: str | None = None - self._durable_constraint_store: DurableConstraintStore | None = None - self._ticktick_client: TickTickMcpClient | None = None - self._constraint_store: ConstraintStore | None = None - self._constraint_engine = None - self._constraint_agent = self._build_constraint_agent() - self._constraint_retriever = ConstraintRetriever() - _journal = _build_journal_store() - self._timebox_patcher = _maybe_journal_patcher(TimeboxPatcher(), _journal) - self._calendar_submitter = _maybe_journal_submitter( - CalendarSubmitter(), _journal - ) - self._task_marshalling = TaskMarshallingCapability( - send_message=self.send_message, - timeout_s=TIMEBOXING_TIMEOUTS.tasks_snapshot_s, - source_resolver=self._agent_source, - ) - self._scheduler_prefetch = SchedulerPrefetchCapability( - queue_constraint_prefetch=self._queue_constraint_prefetch, - await_pending_durable_prefetch=self._await_pending_durable_constraint_prefetch, - ensure_calendar_immovables=self._ensure_calendar_immovables, - prefetch_calendar_immovables=self._prefetch_calendar_immovables, - is_collect_stage_loaded=self._is_collect_stage_loaded, - ) - self._constraint_search_tool: FunctionTool | None = None - self._durable_constraint_task_keys: set[str] = set() - self._durable_dedupe_task_keys: set[str] = set() - self._durable_constraint_semaphore = asyncio.Semaphore( - TIMEBOXING_LIMITS.durable_upsert_concurrency - ) - self._durable_constraint_prefetch_tasks: dict[str, asyncio.Task] = {} - self._durable_constraint_prefetch_semaphore = asyncio.Semaphore( - TIMEBOXING_LIMITS.durable_prefetch_concurrency - ) - self._constraint_extraction_tasks: dict[str, asyncio.Task] = {} - self._constraint_extraction_semaphore = asyncio.Semaphore( - TIMEBOXING_LIMITS.constraint_extract_concurrency - ) - # NLU agents (_constraint_interpreter_agent, _planning_date_interpreter_agent, - # _memory_review_agent) intentionally removed: each call site builds a fresh - # agent via the factory function so no multi-turn history accumulates. - # Stage-gating agents (_stage_agents, _decision_agent, _summary_agent, - # _review_commit_agent) similarly removed for the same reason. - self._session_debug_loggers: dict[str, logging.Logger] = {} - self._constraint_mcp_tools: list | None = None - # TODO(deprecate): _notion_extractor and _constraint_extractor_tool are part - # of the dead Notion-MCP extraction path. Remove with _ensure_constraint_mcp_tools. - self._notion_extractor: NotionConstraintExtractor | None = None - self._constraint_extractor_tool: FunctionTool | None = None - - # region helpers - - def _session_key(self, ctx: MessageContext, *, fallback: str | None = None) -> str: - """Return a stable session key for the current routing context.""" - if fallback: - return fallback - topic_key = ctx.topic_id.source if ctx.topic_id else None - if topic_key: - return topic_key - agent = ctx.sender if ctx.sender else None - return agent.key if agent else "default" - - def _agent_source(self) -> str: - """Return a safe message source identifier for TextMessage outputs.""" - try: - return self.id.type - except Exception: - return "timeboxing_agent" - - def _default_tz_name(self) -> str: - """Return the default timezone name for planning.""" - return settings.planning_timezone - - def _resolve_tz_name(self, tz_name: str | None) -> str: - """Normalize an IANA timezone name to a valid value.""" - candidate = (tz_name or "").strip() or "UTC" - try: - ZoneInfo(candidate) - return candidate - except Exception: - return "UTC" - - def _ensure_uncommitted_session( - self, - *, - key: str, - thread_ts: str, - channel_id: str, - user_id: str, - user_input: str, - tz_name: str, - default_planned_date: str, - debug_event: str, - start_message: str | None = None, - ) -> tuple[Session, bool]: - """Get existing session or create an uncommitted session deterministically.""" - session = self._sessions.get(key) - if session: - if session.session_key is None: - session.session_key = key - return session, False - session = Session( - thread_ts=thread_ts, - channel_id=channel_id, - user_id=user_id, - last_user_message=user_input, - start_message=start_message, - committed=False, - planned_date=default_planned_date, - tz_name=tz_name, - session_key=key, - ) - self._sessions[key] = session - self._session_debug( - session, - debug_event, - committed=False, - user_input=(user_input or "")[:500], - ) - return session, True - - def _default_planned_date(self, *, now: datetime, tz: ZoneInfo) -> str: - """Return a deterministic default planned date. - - This is a fallback used only when the user did not specify a date. - We avoid any "workday" logic here (no weekday/weekend assumptions). - - Rule: - - Before 09:00 local time β†’ use today. - - At/after 09:00 local time β†’ use tomorrow. - """ - local_now = now.astimezone(tz) - planned = local_now.date() - if (local_now.hour, local_now.minute) >= (9, 0): - planned = planned + timedelta(days=1) - return planned.isoformat() - - def _refresh_temporal_facts(self, session: Session) -> None: - """Refresh timezone-local temporal anchors used by stage prompts.""" - tz_name = (session.tz_name or "UTC").strip() or "UTC" - try: - tz = ZoneInfo(tz_name) - except Exception: - tz_name = "UTC" - tz = ZoneInfo("UTC") - session.tz_name = tz_name - local_now = datetime.now(timezone.utc).astimezone(tz) - session.frame_facts["date"] = ( - session.planned_date or local_now.date().isoformat() - ) - session.frame_facts["timezone"] = tz_name - session.frame_facts["current_time"] = local_now.strftime("%H:%M") - session.frame_facts["current_datetime"] = local_now.isoformat( - timespec="minutes" - ) - - @staticmethod - def _is_truthy_env(value: str | None) -> bool: - """Interpret common truthy env values.""" - if value is None: - return False - return value.strip().lower() in {"1", "true", "yes", "on", "debug"} - - def _session_debug_enabled(self) -> bool: - """Return whether per-session debug log files should be written.""" - explicit = os.getenv("TIMEBOX_SESSION_DEBUG_LOG") - if explicit is None: - try: - return self._is_truthy_env( - os.getenv("DEBUG") or os.getenv("FATEFORGER_DEBUG") - ) or (sys.gettrace() is not None) - except Exception: - return self._is_truthy_env( - os.getenv("DEBUG") or os.getenv("FATEFORGER_DEBUG") - ) - return self._is_truthy_env(explicit) - - @staticmethod - def _safe_session_log_key(raw: str) -> str: - """Convert a session key into a filename-safe token.""" - allowed = set( - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_.-" - ) - normalized = "".join(ch if ch in allowed else "_" for ch in raw).strip("._") - while "__" in normalized: - normalized = normalized.replace("__", "_") - return normalized or "session" - - def _ensure_session_debug_logger(self, session: Session) -> logging.Logger | None: - """Create or reuse a dedicated per-session logger.""" - if not self._session_debug_enabled(): - return None - session_loggers = getattr(self, "_session_debug_loggers", None) - if session_loggers is None: - session_loggers = {} - setattr(self, "_session_debug_loggers", session_loggers) - session_key = session.session_key or f"{session.channel_id}:{session.thread_ts}" - existing = session_loggers.get(session_key) - if existing: - return existing - safe_key = self._safe_session_log_key(session_key) - ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") - log_dir = Path(os.getenv("TIMEBOX_SESSION_LOG_DIR", "logs")) - log_dir.mkdir(parents=True, exist_ok=True) - file_path = log_dir / f"timeboxing_session_{ts}_{safe_key}_{os.getpid()}.log" - logger_name = ( - f"fateforger.agents.timeboxing.session.{safe_key}.{ts}.{os.getpid()}" - ) - session_logger = logging.getLogger(logger_name) - session_logger.setLevel(logging.DEBUG) - session_logger.propagate = False - if not any( - getattr(h, "_fftb_session_file", False) for h in session_logger.handlers - ): - handler = logging.FileHandler(file_path, encoding="utf-8") - handler.setLevel(logging.DEBUG) - handler.setFormatter( - logging.Formatter("%(asctime)s %(levelname)s %(message)s") - ) - setattr(handler, "_fftb_session_file", True) - session_logger.addHandler(handler) - session.debug_log_path = str(file_path) - session_loggers[session_key] = session_logger - append_index_entry( - index_path=log_dir - / os.getenv("TIMEBOX_SESSION_INDEX_FILE", "timeboxing_session_index.jsonl"), - entry={ - "type": "timeboxing_session", - "created_at": datetime.now(timezone.utc).isoformat(), - "session_key": session.session_key, - "thread_ts": session.thread_ts, - "channel_id": session.channel_id, - "user_id": session.user_id, - "planned_date": session.planned_date, - "stage": session.stage.value if session.stage else None, - "log_path": str(file_path), - "pid": os.getpid(), - }, - ) - logger.info("Timeboxing session debug logging enabled: %s", file_path) - return session_logger - - def _session_debug(self, session: Session, event: str, **payload: Any) -> None: - """Write a JSON debug event to the session log when enabled.""" - session_logger = self._ensure_session_debug_logger(session) - if not session_logger: - return - data: dict[str, Any] = { - "event": event, - "session_key": session.session_key, - "thread_ts": session.thread_ts, - "channel_id": session.channel_id, - "planned_date": session.planned_date, - "stage": session.stage.value if session.stage else None, - } - data.update(payload) - session_logger.info(json.dumps(data, ensure_ascii=False, default=str)) - - # TODO: remove all these if statements - def _close_session_debug_logger(self, session_key: str) -> None: - """Close and detach file handlers for a session logger.""" - session_loggers = getattr(self, "_session_debug_loggers", None) - if session_loggers is None: - return - session_logger = session_loggers.pop(session_key, None) - if not session_logger: - return - for handler in list(session_logger.handlers): - session_logger.removeHandler(handler) - try: - handler.close() - except Exception: - pass - - # TODO: remove this fallback, it should just already be there - def _ensure_graphflow(self, session: Session) -> GraphFlow: - """Return the per-session GraphFlow instance, building it if needed.""" - if session.graphflow is not None: - return session.graphflow - session.graphflow = build_timeboxing_graphflow( - orchestrator=self, session=session - ) - return session.graphflow - - # TODO: remove this, we need to rely on the autogen framework to handle this - async def _run_graph_turn(self, *, session: Session, user_text: str) -> TextMessage: - """Run one GraphFlow turn and return the presenter text message.""" - async with session.graph_turn_lock: - turn_started_at = perf_counter() - turn_elapsed_s = lambda: round(perf_counter() - turn_started_at, 3) - session.graph_turn_started_at_monotonic = turn_started_at - session.graph_turn_deadline_monotonic = ( - turn_started_at + TIMEBOXING_TIMEOUTS.graph_turn_s - ) - self._refresh_temporal_facts(session) - self._session_debug( - session, - "graph_turn_start", - user_text=(user_text or "")[:500], - ) - flow = self._ensure_graphflow(session) - presenter: TextMessage | None = None - - async def _run_stream() -> TextMessage | None: - presenter_message: TextMessage | None = None - async for item in flow.run_stream( - task=TextMessage(content=user_text, source="user") - ): - if isinstance(item, TextMessage) and item.source == "PresenterNode": - presenter_message = item - return presenter_message - - try: - try: - presenter = await with_timeout( - "timeboxing:graph-turn", - _run_stream(), - timeout_s=TIMEBOXING_TIMEOUTS.graph_turn_s, - dump_on_timeout=False, - dump_threads_on_timeout=False, - ) - except TimeoutError as exc: - timeout_message = "This turn hit a processing timeout. Reply `Redo` to retry this stage." - self._session_debug( - session, - "graph_turn_timeout", - error_type=type(exc).__name__, - timeout_s=TIMEBOXING_TIMEOUTS.graph_turn_s, - elapsed_s=turn_elapsed_s(), - ) - self._session_debug( - session, - "graph_turn_end", - presenter_found=False, - output_preview=timeout_message[:500], - elapsed_s=turn_elapsed_s(), - ) - observe_stage_duration( - stage=session.stage.value if session.stage else "unknown", - duration_s=turn_elapsed_s(), - ) - return TextMessage(content=timeout_message, source=self.id.type) - except Exception as exc: - self._session_debug( - session, - "graph_turn_error", - error_type=type(exc).__name__, - error=str(exc)[:2000], - elapsed_s=turn_elapsed_s(), - ) - raise - content = ( - presenter.content - if presenter - else "No stage response was generated. Reply `Redo` to retry this stage." - ) - elapsed_s = turn_elapsed_s() - self._session_debug( - session, - "graph_turn_end", - presenter_found=presenter is not None, - output_preview=content[:500], - elapsed_s=elapsed_s, - ) - observe_stage_duration( - stage=session.stage.value if session.stage else "unknown", - duration_s=elapsed_s, - ) - if elapsed_s >= TIMEBOXING_TIMEOUTS.slow_turn_warn_s: - self._session_debug( - session, - "graph_turn_slow", - elapsed_s=elapsed_s, - threshold_s=TIMEBOXING_TIMEOUTS.slow_turn_warn_s, - user_text_preview=(user_text or "")[:200], - ) - return TextMessage(content=content, source=self.id.type) - finally: - session.graph_turn_started_at_monotonic = None - session.graph_turn_deadline_monotonic = None - - # TODO: this should be build into the agent itself using the autogen message in that stage, not bolted on like this - async def _interpret_planned_date( - self, text: str, *, now: datetime, tz_name: str - ) -> str: - """Interpret the user's intended planning date using structured multilingual parsing. - - A fresh ``AssistantAgent`` is created on every call so no prior-turn - message history accumulates inside the interpreter. - """ - tz = ZoneInfo(tz_name) - if not (text or "").strip(): - return self._default_planned_date(now=now, tz=tz) - date_agent = build_planned_date_interpreter(model_client=self._model_client) - payload = { - "text": text, - "now_utc": now.isoformat(), - "timezone": tz_name, - } - try: - response = await with_timeout( - "timeboxing:planning-date", - date_agent.on_messages( - [ - TextMessage( - content=json.dumps(payload, ensure_ascii=False), - source="user", - ) - ], - CancellationToken(), - ), - timeout_s=TIMEBOXING_TIMEOUTS.planning_date_interpret_s, - dump_on_timeout=False, - dump_threads_on_timeout=False, - ) - result = parse_chat_content(PlannedDateResult, response) - if result.planned_date: - return result.planned_date - except Exception: - logger.debug( - "Planned date interpretation failed; using default.", exc_info=True - ) - return self._default_planned_date(now=now, tz=tz) - - async def _decide_memory_review_turn( - self, - *, - session: Session, - user_message: str, - ) -> MemoryReviewDecision: - """Route user replies that should trigger immediate memory review.""" - message = (user_message or "").strip() - if not message: - return MemoryReviewDecision(action="none") - if getattr(self, "_model_client", None) is None: - return MemoryReviewDecision(action="none") - # Fresh agent per call β€” no accumulated multi-turn history. - memory_agent = build_memory_review_router(model_client=self._model_client) - payload = { - "stage": session.stage.value if session.stage else None, - "stage_ready": bool(session.stage_ready), - "pending_submit": bool(session.pending_submit), - "user_message": message, - } - try: - response = await with_timeout( - "timeboxing:memory-review-decision", - memory_agent.on_messages( - [ - TextMessage( - content=json.dumps(payload, ensure_ascii=False), - source="user", - ) - ], - CancellationToken(), - ), - timeout_s=TIMEBOXING_TIMEOUTS.stage_decision_s, - ) - decision = parse_chat_content(MemoryReviewDecision, response) - self._session_debug( - session, - "memory_review_decision", - action=decision.action, - text_query=decision.text_query, - statuses=list(decision.statuses or []), - scopes=list(decision.scopes or []), - necessities=list(decision.necessities or []), - tags=list(decision.tags or []), - limit=decision.limit, - ) - return decision - except Exception as exc: - self._session_debug( - session, - "memory_review_decision_error", - error_type=type(exc).__name__, - error=str(exc)[:1000], - ) - return MemoryReviewDecision(action="none") - - # TODO: this should be handled by the mcpworkbench, not a re-implementation - def _ensure_calendar_client(self) -> McpCalendarClient | None: - """Return the calendar MCP client, initializing it if needed.""" - if not hasattr(self, "_calendar_client"): - # Uninitialized test doubles created via ``__new__`` should not - # instantiate live MCP clients. - return None - existing = self._calendar_client - if existing: - return existing - server_url = settings.mcp_calendar_server_url.strip() - if not server_url: - return None - try: - self._calendar_client = McpCalendarClient( - server_url=server_url, - timeout=float( - getattr(settings, "agent_mcp_discovery_timeout_seconds", 10) - ), - ) - except Exception: - logger.error("Failed to initialize MCP calendar client", exc_info=True) - return None - return self._calendar_client - - # TODO: this should be handled by the mcpworkbench, not a re-implementation - def _ensure_ticktick_client(self) -> TickTickMcpClient | None: - """Return the TickTick MCP client, initializing it if needed.""" - if self._ticktick_client: - return self._ticktick_client - server_url = get_ticktick_mcp_url() - if not server_url: - return None - try: - self._ticktick_client = TickTickMcpClient( - server_url=server_url, - timeout=float( - getattr(settings, "agent_mcp_discovery_timeout_seconds", 10) - ), - ) - except Exception: - logger.error("Failed to initialize TickTick MCP client", exc_info=True) - return None - return self._ticktick_client - - # TODO: this should be handled by the mcpworkbench, not a re-implementation - def _ensure_constraint_memory_client(self) -> Any | None: - """Return the constraint-memory MCP client, initializing it if needed.""" - if self._constraint_memory_client: - return self._constraint_memory_client - if self._constraint_memory_unavailable_reason: - return None - backend = str(getattr(settings, "timeboxing_memory_backend", "constraint_mcp")) - backend = backend.strip().lower() - try: - timeout = float( - getattr(settings, "agent_mcp_discovery_timeout_seconds", 10) - ) - match backend: - case "constraint_mcp": - self._constraint_memory_client = ConstraintMemoryClient( - timeout=timeout - ) - case "memory_kg": - # The store the standalone memory server owns -- the same - # corpus /dsh reads. Read-only by design: a constraint there - # is projected from the observation log, so writes go - # through memory_observe rather than straight into L2. - from .kg_constraint_client import KGConstraintMemoryClient - - configured = str( - getattr(settings, "memory_db_path", "") or "" - ).strip() - self._constraint_memory_client = KGConstraintMemoryClient( - configured - or str(Path(__file__).resolve().parents[3] / "data" / "memory.db") - ) - case "graphiti": - user_id = ( - str(getattr(settings, "graphiti_user_id", "") or "").strip() - or "timeboxing" - ) - self._constraint_memory_client = build_graphiti_client_from_settings( - user_id=user_id - ) - case _: - raise ValueError( - "Unsupported timeboxing memory backend: " - f"{settings.timeboxing_memory_backend}" - ) - self._constraint_memory_unavailable_reason = None - except Exception as exc: - self._constraint_memory_unavailable_reason = f"{type(exc).__name__}: {exc}" - logger.warning( - "Failed to initialize %s constraint memory client; disabling retries " - "for this runtime instance (%s)", - backend, - self._constraint_memory_unavailable_reason, - ) - return None - return self._constraint_memory_client - - def _ensure_durable_constraint_store(self) -> DurableConstraintStore | None: - """Return a backend-neutral durable-memory store adapter.""" - existing = getattr(self, "_durable_constraint_store", None) - if existing is not None: - return existing - client = self._ensure_constraint_memory_client() - store = build_durable_constraint_store(client) - self._durable_constraint_store = store - return store - - # TODO: thia should be a tool, not a bolted on method - async def _fetch_durable_constraints( - self, session: Session, *, stage: TimeboxingStage - ) -> List[Constraint]: - """Fetch durable constraints for a stage from the configured memory backend.""" - store = self._ensure_durable_constraint_store() - if not store: - self._session_debug( - session, - "durable_store_unavailable", - requested_stage=stage.value, - backend=str(getattr(settings, "timeboxing_memory_backend", "")).strip() - or "unknown", - unavailable_reason=self._constraint_memory_unavailable_reason - or "no_store", - ) - self._append_background_update_once( - session, - "Saved constraints backend is unavailable; reusing local profile/date defaults if present.", - ) - return [] - try: - planned_day = date.fromisoformat( - session.planned_date or datetime.utcnow().date().isoformat() - ) - except Exception: - planned_day = datetime.utcnow().date() - try: - store_info = await store.get_store_info() - except Exception as exc: - store_info = { - "backend": "unknown", - "error_type": type(exc).__name__, - "error": str(exc)[:200], - } - self._session_debug( - session, - "durable_fetch_start", - requested_stage=stage.value, - planned_day=planned_day.isoformat(), - store_info=store_info, - ) - - work_window = parse_model_optional( - WorkWindow, session.frame_facts.get("work_window") - ) - sleep_target = parse_model_optional( - SleepTarget, session.frame_facts.get("sleep_target") - ) - immovables = parse_model_list(Immovable, session.frame_facts.get("immovables")) - block_plan = parse_model_optional( - BlockPlan, session.input_facts.get("block_plan") - ) - - try: - _plan, raw_records = await self._constraint_retriever.retrieve( - client=store, - stage=stage, - planned_day=planned_day, - work_window=work_window, - sleep_target=sleep_target, - immovables=immovables, - block_plan=block_plan, - frame_facts=dict(session.frame_facts or {}), - ) - reconciled = reconcile_constraint_rows( - rows=list(raw_records or []), - planned_day=planned_day, - stage=stage.value, - ) - plan_payload = ( - _plan.model_dump(mode="json") if hasattr(_plan, "model_dump") else {} - ) - self._session_debug( - session, - "durable_constraints_selected", - requested_stage=stage.value, - raw_count=reconciled.raw_count, - canonical_count=reconciled.canonical_count, - selected_count=reconciled.applicable_count, - duplicate_groups=reconciled.duplicate_groups, - selected_uids=[ - str((row or {}).get("uid") or "").strip() - for row in reconciled.applicable_rows[:20] - if str((row or {}).get("uid") or "").strip() - ], - selected_names=[ - str((row or {}).get("name") or "").strip() - for row in reconciled.applicable_rows[:10] - if str((row or {}).get("name") or "").strip() - ], - query_plan=plan_payload, - ) - except Exception as exc: - # This is a background prefetch and we want failures to be visible in Slack. - msg = f"Durable constraints failed to load: {type(exc).__name__}: {exc}" - session.background_updates.append(msg) - logger.error(msg, exc_info=True) - self._session_debug( - session, - "durable_constraints_select_error", - requested_stage=stage.value, - error_type=type(exc).__name__, - error=str(exc)[:500], - ) - raise - return _constraints_from_memory( - reconciled.applicable_rows, user_id=session.user_id - ) - - # TODO: this should be leveraging the autogen framework by having a constraints agent that has Constraint as it message type rather than a bolted on message.. - async def _interpret_constraints( - self, session: Session, *, text: str, is_initial: bool - ) -> ConstraintInterpretation: - """Interpret constraints + scope from user text using a single structured LLM call. - - A fresh ``AssistantAgent`` is created on every call so no prior-turn - message history accumulates inside the interpreter. - """ - interpreter = build_constraint_interpreter( - model_client=self._constraint_model_client - ) - payload = _ConstraintInterpretationPayload( - text=text, - is_initial=is_initial, - planned_date=session.planned_date, - timezone=session.tz_name, - stage_id=session.stage.value if session.stage else None, - ) - response = await with_timeout( - "timeboxing:constraint-interpret", - interpreter.on_messages( - [TextMessage(content=payload.model_dump_json(), source="user")], - CancellationToken(), - ), - timeout_s=TIMEBOXING_TIMEOUTS.constraint_interpret_s, - ) - return parse_chat_content(ConstraintInterpretation, response) - - # TODO: this should be part of a tool, not bolted onto an agent - def _constraint_task_key(self, session: Session, text: str) -> str: - """Return a stable hash for deduping constraint extraction tasks.""" - payload = { - "user_id": session.user_id, - "channel_id": session.channel_id, - "thread_ts": session.thread_ts, - "text": text.strip(), - } - return hashlib.sha256( - json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8") - ).hexdigest()[: TIMEBOXING_LIMITS.durable_task_key_len] - - # TODO: this should be part of a tool, not bolted onto an agent - def _durable_prefetch_key(self, session: Session) -> str: - """Return a stable key for deduping durable constraint prefetch tasks.""" - planned_date = session.planned_date or "unknown" - return f"{session.user_id}:{session.thread_ts}:{planned_date}" - - def _durable_prefetch_stage_key( - self, session: Session, *, stage: TimeboxingStage - ) -> str: - """Return a stable key for one stage-scoped durable prefetch task.""" - return f"{self._durable_prefetch_key(session)}:{stage.value}" - - @staticmethod - def _durable_prefetch_stages( - *, include_secondary: bool = True - ) -> tuple[TimeboxingStage, ...]: - """Return deterministic durable-prefetch stage groups.""" - if include_secondary: - return ( - TimeboxingStage.COLLECT_CONSTRAINTS, - TimeboxingStage.SKELETON, - TimeboxingStage.REFINE, - ) - return (TimeboxingStage.COLLECT_CONSTRAINTS,) - - @staticmethod - def _is_collect_stage_loaded(session: Session) -> bool: - """Return whether Stage 1 durable constraints are loaded for the current date.""" - planned_date = session.planned_date or "" - return bool( - session.durable_constraints_date == planned_date - and TimeboxingStage.COLLECT_CONSTRAINTS.value - in session.durable_constraints_loaded_stages - ) - - def _reset_durable_prefetch_state(self, session: Session) -> None: - """Clear cached durable-prefetch state when planned date changes.""" - session.durable_constraints_by_stage = {} - session.durable_constraints_loaded_stages = set() - session.pending_durable_stages = set() - session.pending_durable_constraints = False - session.durable_constraints_failed_stages = {} - session.durable_constraints_date = None - - def _queue_durable_prefetch_stage( - self, - *, - session: Session, - stage: TimeboxingStage, - reason: str, - ) -> asyncio.Task | None: - """Queue one stage-scoped durable prefetch task with in-flight dedupe.""" - planned_date = session.planned_date or "" - if ( - session.durable_constraints_date == planned_date - and stage.value in session.durable_constraints_loaded_stages - ): - return None - task_key = self._durable_prefetch_stage_key(session, stage=stage) - existing = self._durable_constraint_prefetch_tasks.get(task_key) - if existing: - return existing - - async def _background() -> None: - """Fetch durable constraints for one stage.""" - acquired = False - stage_label = stage.value - session.pending_durable_stages.add(stage_label) - session.pending_durable_constraints = True - task_planned_date = planned_date - try: - await self._durable_constraint_prefetch_semaphore.acquire() - acquired = True - constraints = await self._fetch_durable_constraints( - session, stage=stage - ) - # Ignore stale results when the session date changed while fetching. - if (session.planned_date or "") != task_planned_date: - return - session.durable_constraints_by_stage[stage_label] = constraints - session.durable_constraints_loaded_stages.add(stage_label) - session.durable_constraints_failed_stages.pop(stage_label, None) - session.durable_constraints_date = task_planned_date - self._session_debug( - session, - "durable_prefetch_stage_loaded", - reason=reason, - prefetched_stage=stage_label, - planned_date=task_planned_date, - count=len(constraints), - names=[c.name for c in constraints[:10] if (c.name or "").strip()], - ) - if constraints: - self._append_background_update_once( - session, - f"Loaded {len(constraints)} saved constraint(s) for {stage_label}.", - ) - await self._sync_durable_constraints_to_store( - session, constraints=constraints - ) - await self._collect_constraints(session) - except Exception as exc: - if (session.planned_date or "") != task_planned_date: - return - details = str(exc).strip() - if len(details) > 240: - details = details[:237] + "..." - msg = f"Durable constraint prefetch failed (stage={stage_label}, reason={reason})" - if details: - msg = f"{msg}: {details}" - session.durable_constraints_failed_stages[stage_label] = msg - self._append_background_update_once(session, msg) - logger.error(msg, exc_info=True) - finally: - if acquired: - self._durable_constraint_prefetch_semaphore.release() - session.pending_durable_stages.discard(stage_label) - session.pending_durable_constraints = bool( - session.pending_durable_stages - ) - self._durable_constraint_prefetch_tasks.pop(task_key, None) - - task = asyncio.create_task(_background()) - self._durable_constraint_prefetch_tasks[task_key] = task - return task - - def _queue_constraint_prefetch(self, session: Session) -> None: - """Prefetch session-scoped constraints and durable constraints in background.""" - self._task_marshalling.queue_prefetch( - session=session, - reason="prefetch", - append_background_update=self._append_background_update_once, - ) - self._queue_durable_constraint_prefetch( - session=session, reason="prefetch", include_secondary=True - ) - if session.constraints_prefetched: - return - if not settings.database_url: - return - - async def _background() -> None: - """Fetch session constraints on a background task.""" - acquired = False - try: - await self._constraint_extraction_semaphore.acquire() - acquired = True - await self._ensure_constraint_store() - await self._collect_constraints(session) - except Exception: - logger.debug("Constraint prefetch failed", exc_info=True) - finally: - if acquired: - self._constraint_extraction_semaphore.release() - session.constraints_prefetched = True - - asyncio.create_task(_background()) - - async def _prime_collect_prefetch_non_blocking( - self, *, session: Session, planned_date: str, blocking: bool = False - ) -> None: - """Prime collect-stage prefetch with optional blocking stage gate.""" - scheduler_prefetch = getattr(self, "_scheduler_prefetch", None) - if scheduler_prefetch is not None: - await scheduler_prefetch.prime_committed_collect_context( - session=session, - blocking=blocking, - ) - return - if blocking: - await self._await_pending_durable_constraint_prefetch( - session, - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - ) - if planned_date: - await self._prefetch_calendar_immovables(session, planned_date) - else: - await self._ensure_calendar_immovables( - session, timeout_s=TIMEBOXING_TIMEOUTS.calendar_prefetch_wait_s - ) - return - if planned_date: - asyncio.create_task( - self._prefetch_calendar_immovables(session, planned_date) - ) - self._queue_constraint_prefetch(session) - - def _queue_durable_constraint_prefetch( - self, - *, - session: Session, - reason: str, - include_secondary: bool = True, - ) -> None: - """Start background durable constraint prefetch if needed.""" - planned_date = session.planned_date or "" - if ( - session.durable_constraints_date - and session.durable_constraints_date != planned_date - ): - self._reset_durable_prefetch_state(session) - - stages = self._durable_prefetch_stages(include_secondary=include_secondary) - for stage in stages: - self._queue_durable_prefetch_stage( - session=session, - stage=stage, - reason=reason, - ) - - def _queue_constraint_extraction( - self, - *, - session: Session, - text: str, - reason: str, - is_initial: bool, - ) -> asyncio.Task | None: - """Queue background extraction for session constraints and optional durable upsert.""" - if not (text or "").strip(): - return None - task_key = self._constraint_task_key(session, text) - if task_key in self._constraint_extraction_tasks: - return None - - async def _background() -> ConstraintBatch | None: - """Extract constraints from user text on a background task.""" - acquired = False - try: - await self._constraint_extraction_semaphore.acquire() - acquired = True - interpretation = await self._interpret_constraints( - session, text=text, is_initial=is_initial - ) - _stamp_extraction_reason( - interpretation.constraints or [], reason=reason - ) - session.last_extracted_constraints_count = len( - interpretation.constraints or [] - ) - self._session_debug( - session, - "constraint_extraction_result", - reason=reason, - is_initial=is_initial, - should_extract=bool(interpretation.should_extract), - extracted_count=len(interpretation.constraints or []), - scope=interpretation.scope, - names=[ - c.name - for c in (interpretation.constraints or [])[:10] - if (c.name or "").strip() - ], - ) - if not interpretation.should_extract: - return None - - scope = ConstraintScope(interpretation.scope) - # Persist extracted constraints to the session store (non-blocking UX). - await self._ensure_constraint_store() - constraints = list(interpretation.constraints or []) - if constraints: - for constraint in constraints: - if constraint.scope is None: - constraint.scope = scope - if self._constraint_needs_confirmation(constraint): - hints = ( - dict(constraint.hints) - if isinstance(constraint.hints, dict) - else {} - ) - hints["needs_confirmation"] = True - constraint.hints = hints - if scope == ConstraintScope.DATESPAN: - if ( - interpretation.start_date - and constraint.start_date is None - ): - # TODO(refactor): Parse dates via a Pydantic schema. - try: - constraint.start_date = date.fromisoformat( - interpretation.start_date - ) - except Exception: - pass - if interpretation.end_date and constraint.end_date is None: - # TODO(refactor): Parse dates via a Pydantic schema. - try: - constraint.end_date = date.fromisoformat( - interpretation.end_date - ) - except Exception: - pass - if self._constraint_store: - if hasattr(self._constraint_store, "upsert_constraints"): - await self._constraint_store.upsert_constraints( - user_id=session.user_id, - channel_id=session.channel_id, - thread_ts=session.thread_ts, - constraints=constraints, - ) - else: - await self._constraint_store.add_constraints( - user_id=session.user_id, - channel_id=session.channel_id, - thread_ts=session.thread_ts, - constraints=constraints, - ) - self._session_debug( - session, - "constraint_local_persisted", - reason=reason, - count=len(constraints), - scope=scope.value, - ) - await self._collect_constraints(session) - if scope in (ConstraintScope.PROFILE, ConstraintScope.DATESPAN): - self._session_debug( - session, - "durable_upsert_enqueued", - reason=reason, - count=len(constraints), - scope=scope.value, - ) - self._queue_durable_constraint_upsert( - session=session, - text=text, - reason=reason, - decision_scope=scope.value, - constraints=constraints, - ) - return ConstraintBatch(constraints=constraints) - return None - except Exception as exc: - logger.warning( - "Constraint extraction failed (reason=%s task_key=%s): %s", - reason, - task_key, - exc, - exc_info=True, - ) - self._append_background_update_once( - session, - "Couldn't update remembered constraints from that message. " - "Calendar patching can still continue.", - ) - self._session_debug( - session, - "constraint_extraction_error", - reason=reason, - task_key=task_key, - error_type=type(exc).__name__, - error=str(exc)[:500], - ) - return None - finally: - if acquired: - self._constraint_extraction_semaphore.release() - session.pending_constraint_extractions.discard(task_key) - self._constraint_extraction_tasks.pop(task_key, None) - - session.pending_constraint_extractions.add(task_key) - task = asyncio.create_task(_background()) - self._constraint_extraction_tasks[task_key] = task - return task - - def _queue_durable_constraint_upsert( - self, - *, - session: Session, - text: str, - reason: str, - decision_scope: str | None, - constraints: list[ConstraintBase] | None = None, - ) -> None: - """Queue durable constraint upserts into the configured durable-memory backend.""" - if not (text or "").strip(): - return - - serialized_constraints = [ - constraint.model_dump(mode="json") for constraint in (constraints or []) - ] - payload = { - "planned_date": session.planned_date or "", - "timezone": session.tz_name or "UTC", - "stage_id": session.stage.value, - "user_utterance": text, - "triggering_suggestion": reason, - "impacted_event_types": [], - "suggested_tags": [], - "decision_scope": decision_scope, - "constraints": serialized_constraints, - } - task_key = hashlib.sha256( - json.dumps(payload, sort_keys=True, ensure_ascii=False).encode("utf-8") - ).hexdigest()[: TIMEBOXING_LIMITS.durable_task_key_len] - if task_key in self._durable_constraint_task_keys: - return - if ( - len(self._durable_constraint_task_keys) - >= TIMEBOXING_LIMITS.durable_task_queue_limit - ): - return - self._durable_constraint_task_keys.add(task_key) - self._session_debug( - session, - "durable_upsert_queued", - reason=reason, - task_key=task_key, - count=len(serialized_constraints), - decision_scope=decision_scope, - ) - - async def _background() -> None: - """Upsert durable constraints on a background task.""" - acquired = False - try: - await self._durable_constraint_semaphore.acquire() - acquired = True - persisted = await self._upsert_constraints_to_durable_store( - session=session, - constraints=constraints or [], - user_utterance=payload["user_utterance"], - triggering_suggestion=payload["triggering_suggestion"] or None, - decision_scope=payload["decision_scope"], - ) - if persisted > 0: - self._session_debug( - session, - "durable_upsert_applied", - task_key=task_key, - persisted=persisted, - ) - self._append_background_update_once( - session, - f"Saved {persisted} durable constraint(s).", - ) - self._queue_durable_constraint_dedupe( - session=session, - reason="post_upsert", - ) - self._reset_durable_prefetch_state(session) - self._queue_durable_constraint_prefetch( - session=session, reason="post_upsert" - ) - else: - self._session_debug( - session, - "durable_upsert_noop", - task_key=task_key, - ) - except Exception: - logger.warning( - "Durable constraint upsert failed (task_key=%s)", - task_key, - exc_info=True, - ) - self._session_debug( - session, - "durable_upsert_error", - task_key=task_key, - ) - self._append_background_update_once( - session, - "Failed to save durable constraint(s); continuing with local session constraints.", - ) - finally: - if acquired: - self._durable_constraint_semaphore.release() - self._durable_constraint_task_keys.discard(task_key) - - asyncio.create_task(_background()) - - def _queue_durable_constraint_dedupe( - self, - *, - session: Session, - reason: str, - ) -> None: - """Queue non-blocking durable dedupe to clean legacy overlaps.""" - if not hasattr(self, "_durable_dedupe_task_keys"): - self._durable_dedupe_task_keys = set() - task_payload = { - "user_id": session.user_id, - "planned_date": session.planned_date, - "stage": session.stage.value if session.stage else None, - "reason": reason, - } - task_key = hashlib.sha256( - json.dumps(task_payload, sort_keys=True, ensure_ascii=False).encode("utf-8") - ).hexdigest()[: TIMEBOXING_LIMITS.durable_task_key_len] - if task_key in self._durable_dedupe_task_keys: - return - self._durable_dedupe_task_keys.add(task_key) - - async def _background() -> None: - acquired = False - try: - await self._durable_constraint_semaphore.acquire() - acquired = True - store = self._ensure_durable_constraint_store() - if store is None: - return - result = await store.dedupe_constraints(limit=2000, dry_run=False) - archived = int(result.get("duplicates_archived") or 0) - if archived > 0: - self._append_background_update_once( - session, - f"Merged and archived {archived} duplicate durable constraint(s).", - ) - if hasattr(self, "_session_debug_loggers"): - self._session_debug( - session, - "durable_dedupe", - reason=reason, - scanned=int(result.get("scanned") or 0), - duplicate_groups=int(result.get("duplicate_groups") or 0), - duplicates_archived=archived, - failed_archives=int(result.get("failed_archives") or 0), - ) - except Exception: - logger.warning( - "Durable constraint dedupe failed (task_key=%s)", - task_key, - exc_info=True, - ) - finally: - if acquired: - self._durable_constraint_semaphore.release() - self._durable_dedupe_task_keys.discard(task_key) - - asyncio.create_task(_background()) - - async def _upsert_constraints_to_durable_store( - self, - *, - session: Session, - constraints: list[ConstraintBase], - user_utterance: str, - triggering_suggestion: str | None, - decision_scope: str | None, - ) -> int: - """Upsert extracted constraints deterministically into the durable MCP store.""" - if not constraints: - return 0 - store = self._ensure_durable_constraint_store() - if store is None: - return 0 - prepared: list[tuple[ConstraintBase, dict[str, Any], dict[str, Any]]] = [] - for constraint in constraints: - try: - record = self._build_durable_constraint_record( - session=session, - constraint=constraint, - decision_scope=decision_scope, - ) - prepared.append( - ( - constraint, - record, - { - "user_utterance": user_utterance, - "triggering_suggestion": triggering_suggestion, - "stage": session.stage.value if session.stage else None, - "event_types": record.get("applies_event_types") or [], - "decision_scope": decision_scope, - "action": "upsert", - "overrode_planner": False, - "extracted_type_id": None, - }, - ) - ) - except Exception: - logger.debug( - "Durable record build failed for constraint=%s", - constraint.name, - exc_info=True, - ) - if not prepared: - return 0 - - equivalents_by_uid: dict[str, dict[str, Any]] = {} - batch_matcher = getattr(store, "find_equivalent_constraints", None) - if callable(batch_matcher): - try: - equivalents_by_uid = await batch_matcher( - records=[record for _, record, _ in prepared], - limit=200, - ) - except Exception: - logger.debug("Durable equivalent batch match failed", exc_info=True) - - persisted = 0 - reused_existing = 0 - created_new = 0 - merge_conflicts = 0 - dedupe_matches = 0 - for constraint, record, event in prepared: - try: - lifecycle = dict( - dict(record.get("constraint_record") or {}).get("lifecycle") or {} - ) - record_uid = str(lifecycle.get("uid") or "").strip() - equivalent = equivalents_by_uid.get(record_uid) if record_uid else None - if equivalent is None and not equivalents_by_uid: - equivalent = await store.find_equivalent_constraint( - record=record, limit=200 - ) - equivalent_uid = "" - equivalent_record: dict[str, Any] = {} - if isinstance(equivalent, dict): - equivalent_uid = str(equivalent.get("uid") or "").strip() - maybe_record = equivalent.get("constraint_record") - if isinstance(maybe_record, dict): - equivalent_record = dict(maybe_record) - - if equivalent_uid and equivalent_record: - dedupe_matches += 1 - incoming_record = dict(record.get("constraint_record") or {}) - merge_fn = getattr(store, "merge_constraint_records", None) - if callable(merge_fn): - merged_record = merge_fn( - current=equivalent_record, - incoming=incoming_record, - ) - else: - merged_record = incoming_record - lifecycle = dict(merged_record.get("lifecycle") or {}) - lifecycle["uid"] = equivalent_uid - lifecycle = _increment_session_appearances(lifecycle) - if _should_auto_promote( - session_appearances=int(lifecycle.get("session_appearances") or 0), - necessity=str(merged_record.get("necessity") or ""), - ): - merged_record["status"] = ConstraintStatus.LOCKED.value - merged_record["lifecycle"] = lifecycle - patch_ops_fn = getattr( - store, "build_constraint_json_patch_ops", None - ) - patch_ops: list[dict[str, Any]] = [] - if callable(patch_ops_fn): - patch_ops = patch_ops_fn( - current=equivalent_record, - merged=merged_record, - ) - if not patch_ops: - reused_existing += 1 - persisted += 1 - continue - - update_result = await store.update_constraint( - uid=equivalent_uid, - patch={ - "constraint_record": merged_record, - "json_patch_ops": patch_ops, - }, - event={ - **event, - "action": "semantic_upsert", - "matched_uid": equivalent_uid, - }, - ) - if update_result.get("updated"): - reused_existing += 1 - persisted += 1 - continue - merge_conflicts += 1 - - result = await store.upsert_constraint(record=record, event=event) - if result.get("uid") or result.get("page_id"): - persisted += 1 - created_new += 1 - except Exception: - logger.debug( - "Deterministic durable upsert failed for constraint=%s", - constraint.name, - exc_info=True, - ) - logger.info( - "durable constraint upsert summary: persisted=%s created=%s reused=%s matches=%s merge_conflicts=%s", - persisted, - created_new, - reused_existing, - dedupe_matches, - merge_conflicts, - ) - self._session_debug( - session, - "durable_upsert_summary", - processed_count=len(prepared), - persisted=persisted, - created=created_new, - reused=reused_existing, - matches=dedupe_matches, - merge_conflicts=merge_conflicts, - ) - return persisted - - def _build_durable_constraint_record( - self, - *, - session: Session, - constraint: ConstraintBase, - decision_scope: str | None, - ) -> dict[str, Any]: - """Map a local extracted constraint to a durable-memory upsert payload.""" - hints = constraint.hints if isinstance(constraint.hints, dict) else {} - selector = constraint.selector if isinstance(constraint.selector, dict) else {} - scope = ( - constraint.scope.value - if constraint.scope - else (decision_scope or "profile") - ) - rule_kind = self._resolve_rule_kind(hints=hints, selector=selector) - scalar_params = self._extract_scalar_params(hints=hints, selector=selector) - windows = self._extract_windows(hints=hints, selector=selector) - uid = self._build_durable_constraint_uid( - session=session, - constraint=constraint, - scope=scope, - rule_kind=rule_kind, - scalar_params=scalar_params, - windows=windows, - ) - topics = [ - str(tag).strip() - for tag in (constraint.tags or []) - if isinstance(tag, str) and str(tag).strip() - ] - if self._should_mark_startup_prefetch( - constraint=constraint, rule_kind=rule_kind - ): - topics.append(STARTUP_PREFETCH_TAG) - topics = list(dict.fromkeys(topics)) - return { - "constraint_record": { - "name": constraint.name, - "description": constraint.description, - "necessity": constraint.necessity.value, - "status": ( - constraint.status.value - if constraint.status is not None - else ConstraintStatus.PROPOSED.value - ), - "source": ( - constraint.source.value - if constraint.source is not None - else ConstraintSource.USER.value - ), - "confidence": constraint.confidence, - "scope": scope, - "applicability": { - "start_date": ( - constraint.start_date.isoformat() - if constraint.start_date is not None - else None - ), - "end_date": ( - constraint.end_date.isoformat() - if constraint.end_date is not None - else None - ), - "days_of_week": [ - day.value for day in (constraint.days_of_week or []) - ], - "timezone": constraint.timezone or session.tz_name, - "recurrence": constraint.recurrence, - }, - "lifecycle": { - "uid": uid, - "supersedes_uids": list(constraint.supersedes or []), - "ttl_days": constraint.ttl_days, - }, - "payload": { - "rule_kind": rule_kind, - "scalar_params": scalar_params, - "windows": windows, - }, - # Carry the LLM-assigned aspect classification forward so that - # _constraints_from_memory can reconstruct hints["aspect_classification"] - # without keyword/regex scanning. - "aspect_classification": hints.get("aspect_classification"), - "applies_stages": self._default_durable_applies_stages(), - "applies_event_types": self._default_durable_event_types(), - "topics": topics, - } - } - - def _build_durable_constraint_uid( - self, - *, - session: Session, - constraint: ConstraintBase, - scope: str, - rule_kind: str | None, - scalar_params: dict[str, Any], - windows: list[dict[str, Any]], - ) -> str: - """Build a stable idempotency key for durable upserts.""" - normalized_tags = sorted( - { - str(tag).strip().lower() - for tag in (constraint.tags or []) - if isinstance(tag, str) and str(tag).strip() - } - ) - normalized_windows = sorted( - { - ( - str(item.get("kind") or "").strip().lower(), - str(item.get("start_time_local") or "").strip(), - str(item.get("end_time_local") or "").strip(), - ) - for item in (windows or []) - if isinstance(item, dict) - } - ) - normalized_scalars = { - key: scalar_params[key] - for key in sorted(scalar_params.keys()) - if key in {"duration_min", "duration_max", "contiguity"} - } - material = { - "user_id": session.user_id, - "scope": scope, - "name": (constraint.name or "").strip().lower(), - # Keep UID stable for semantic updates even when prose wording changes. - "rule_kind": (rule_kind or "").strip().lower(), - "scalar_params": normalized_scalars, - "windows": normalized_windows, - "tags": normalized_tags, - "start_date": ( - constraint.start_date.isoformat() - if constraint.start_date is not None - else None - ), - "end_date": ( - constraint.end_date.isoformat() - if constraint.end_date is not None - else None - ), - "days_of_week": sorted( - day.value for day in (constraint.days_of_week or []) - ), - "timezone": constraint.timezone or session.tz_name, - "recurrence": constraint.recurrence, - } - digest = hashlib.sha256( - json.dumps(material, sort_keys=True, ensure_ascii=False).encode("utf-8") - ).hexdigest() - return f"tb_{digest[:40]}" - - def _resolve_rule_kind( - self, - *, - hints: dict[str, Any], - selector: dict[str, Any], - ) -> str | None: - """Extract a supported durable rule_kind from hint/selector metadata.""" - allowed = { - "prefer_window", - "avoid_window", - "fixed_bedtime", - "min_sleep", - "buffer", - "sequencing", - "capacity", - } - for source in (hints, selector): - value = source.get("rule_kind") - if isinstance(value, str) and value in allowed: - return value - return None - - def _extract_scalar_params( - self, - *, - hints: dict[str, Any], - selector: dict[str, Any], - ) -> dict[str, Any]: - """Extract scalar payload fields from hint/selector metadata.""" - merged: dict[str, Any] = {} - for source in (hints.get("scalar_params"), selector.get("scalar_params")): - if isinstance(source, dict): - merged.update(source) - allowed = {"duration_min", "duration_max", "contiguity"} - return {k: v for k, v in merged.items() if k in allowed} - - def _extract_windows( - self, - *, - hints: dict[str, Any], - selector: dict[str, Any], - ) -> list[dict[str, Any]]: - """Extract optional durable window definitions from metadata.""" - for source in (hints, selector): - windows = source.get("windows") - if isinstance(windows, list): - valid_windows = [] - for item in windows: - if not isinstance(item, dict): - continue - kind = item.get("kind") - start = item.get("start_time_local") - end = item.get("end_time_local") - if ( - isinstance(kind, str) - and isinstance(start, str) - and isinstance(end, str) - ): - valid_windows.append( - { - "kind": kind, - "start_time_local": start, - "end_time_local": end, - } - ) - return valid_windows - return [] - - def _default_durable_applies_stages(self) -> list[str]: - """Return default stage routing for durable constraints.""" - return [ - TimeboxingStage.COLLECT_CONSTRAINTS.value, - TimeboxingStage.CAPTURE_INPUTS.value, - TimeboxingStage.SKELETON.value, - TimeboxingStage.REFINE.value, - TimeboxingStage.REVIEW_COMMIT.value, - ] - - def _default_durable_event_types(self) -> list[str]: - """Return default event-type routing for durable constraints.""" - return ["M", "C", "DW", "SW", "H", "R", "BU", "BG", "PR"] - - def _should_mark_startup_prefetch( - self, *, constraint: ConstraintBase, rule_kind: str | None - ) -> bool: - """Return whether a durable constraint should be startup-prefetched in Stage 1. - - Priority order: - 1. ``hints["aspect_classification"]["is_startup_prefetch"]`` (LLM-assigned). - 2. Explicit :data:`STARTUP_PREFETCH_TAG` in constraint tags (legacy marker). - 3. Keyword/rule_kind fallback for constraints that pre-date classification: - detects sleep, work-window, and availability patterns. - - The keyword fallback will be removed once all stored constraints carry - ``aspect_classification`` with the ``is_startup_prefetch`` flag. - TODO(refactor): remove keyword fallback after aspect_classification is - backfilled on all stored constraints. - """ - hints = constraint.hints if isinstance(constraint.hints, dict) else {} - classification = ConstraintAspectClassification.from_hints(hints) - if classification is not None: - return classification.is_startup_prefetch - # Legacy fallback 1: explicit tag marker. - tags = [str(tag).strip().lower() for tag in (constraint.tags or []) if tag] - if STARTUP_PREFETCH_TAG in tags: - return True - # Legacy fallback 2: keyword/rule_kind detection for pre-classification - # constraints that do not yet carry aspect_classification in hints. - rk = str(rule_kind or "").strip().lower() - if rk in {"fixed_bedtime", "min_sleep"}: - return True - text = ( - f"{constraint.name or ''} {constraint.description or ''} " - f"{' '.join(tags)} {rk}" - ).lower() - sleep_tokens = ("sleep", "bed", "wake") - work_tokens = ("work window", "work hours", "availability") - return any(t in text for t in sleep_tokens) or any( - t in text for t in work_tokens - ) - - async def _await_pending_constraint_extractions( - self, - session: Session, - timeout_s: float = TIMEBOXING_TIMEOUTS.pending_constraints_wait_s, - ) -> None: - """Wait briefly for any pending constraint extraction tasks.""" - if not session.pending_constraint_extractions: - return - task_map = getattr(self, "_constraint_extraction_tasks", {}) - tasks = [ - task_map.get(key) - for key in session.pending_constraint_extractions - ] - tasks = [task for task in tasks if task] - if not tasks: - return - try: - await asyncio.wait(tasks, timeout=timeout_s) - except Exception: - return - - async def _await_pending_durable_constraint_prefetch( - self, - session: Session, - timeout_s: float = TIMEBOXING_TIMEOUTS.durable_prefetch_wait_s, - *, - stage: TimeboxingStage = TimeboxingStage.COLLECT_CONSTRAINTS, - fail_on_timeout: bool = True, - ) -> None: - """Wait for one stage-scoped durable prefetch, capped with timeout.""" - self._queue_durable_constraint_prefetch( - session=session, - reason="await_prefetch", - include_secondary=True, - ) - task_key = self._durable_prefetch_stage_key(session, stage=stage) - task = self._durable_constraint_prefetch_tasks.get(task_key) - if not task: - return - self._append_background_update_once(session, "Loading saved constraints...") - try: - await asyncio.wait_for(asyncio.shield(task), timeout=timeout_s) - except asyncio.TimeoutError: - if not fail_on_timeout: - self._session_debug( - session, - "durable_prefetch_soft_timeout", - stage=stage.value, - timeout_s=timeout_s, - ) - return - msg = ( - f"Saved constraints timed out after {int(timeout_s)}s for {stage.value}. " - "You can continue now and use Redo to retry after a moment." - ) - session.durable_constraints_failed_stages[stage.value] = msg - self._append_background_update_once(session, msg) - logger.error(msg) - except Exception as exc: - msg = ( - f"Saved constraints failed for {stage.value}: " - f"{type(exc).__name__}: {str(exc)[:180]}" - ) - session.durable_constraints_failed_stages[stage.value] = msg - self._append_background_update_once(session, msg) - logger.error(msg, exc_info=True) - - async def _prefetch_calendar_immovables( - self, - session: Session, - planned_date: str, - *, - force_refresh: bool = False, - ) -> None: - """Fetch calendar immovables + remote identity for the planned date.""" - if force_refresh: - had_cached = ( - planned_date in session.prefetched_immovables_by_date - or planned_date in session.prefetched_remote_snapshots_by_date - or planned_date in session.prefetched_event_id_maps_by_date - or planned_date in session.prefetched_remote_event_ids_by_date - ) - session.prefetched_immovables_by_date.pop(planned_date, None) - session.prefetched_remote_snapshots_by_date.pop(planned_date, None) - session.prefetched_event_id_maps_by_date.pop(planned_date, None) - session.prefetched_remote_event_ids_by_date.pop(planned_date, None) - self._session_debug( - session, - "calendar_prefetch_force_refresh", - planned_date=planned_date, - had_cached=had_cached, - ) - if ( - not force_refresh - and planned_date in session.prefetched_immovables_by_date - and planned_date in session.prefetched_remote_snapshots_by_date - ): - self._session_debug( - session, - "calendar_prefetch_skip_cached", - planned_date=planned_date, - ) - return - client = self._ensure_calendar_client() - if not client: - self._append_background_update_once( - session, - "Calendar integration is unavailable right now; share fixed events manually.", - ) - self._session_debug( - session, - "calendar_prefetch_no_client", - planned_date=planned_date, - ) - return - session.pending_calendar_prefetch = True - self._session_debug( - session, - "calendar_prefetch_start", - planned_date=planned_date, - timezone=session.tz_name, - ) - try: - tz = ZoneInfo(session.tz_name or "UTC") - except Exception: - tz = ZoneInfo("UTC") - # TODO(refactor): Validate planned_date with Pydantic before calendar prefetch. - diagnostics: dict[str, Any] = {} - try: - snapshot = await client.list_day_snapshot( - calendar_id="primary", - day=date.fromisoformat(planned_date), - tz=tz, - diagnostics=diagnostics, - ) - immovables = snapshot.immovables - session.prefetched_immovables_by_date[planned_date] = immovables - remote_plan, event_id_map, event_ids_by_index = ( - gcal_response_to_tb_plan_with_identity( - snapshot.response, - plan_date=date.fromisoformat(planned_date), - tz_name=session.tz_name or "UTC", - ) - ) - session.prefetched_remote_snapshots_by_date[planned_date] = remote_plan - session.prefetched_event_id_maps_by_date[planned_date] = dict(event_id_map) - session.prefetched_remote_event_ids_by_date[planned_date] = list( - event_ids_by_index - ) - self._session_debug( - session, - "calendar_prefetch_success", - planned_date=planned_date, - immovable_count=len(immovables), - remote_identity_count=len(event_ids_by_index), - diagnostics=diagnostics, - ) - if immovables: - session.background_updates.append( - f"Loaded {len(immovables)} calendar immovable(s)." - ) - if event_ids_by_index: - session.background_updates.append( - f"Loaded {len(event_ids_by_index)} remote calendar event identity record(s)." - ) - except Exception as exc: - logger.debug("Calendar prefetch failed for %s", planned_date, exc_info=True) - self._session_debug( - session, - "calendar_prefetch_error", - planned_date=planned_date, - error="list_day_snapshot_failed", - error_type=type(exc).__name__, - error_detail=(str(exc) or type(exc).__name__)[:1200], - diagnostics=diagnostics, - ) - self._append_background_update_once( - session, - "Couldn't load calendar events yet; share fixed anchors manually or click Redo.", - ) - finally: - session.pending_calendar_prefetch = False - self._session_debug( - session, - "calendar_prefetch_end", - planned_date=planned_date, - pending=session.pending_calendar_prefetch, - ) - - async def _ensure_calendar_immovables( - self, session: Session, *, timeout_s: float = 4.0 - ) -> None: - """Ensure calendar immovables are fetched and applied to frame facts.""" - planned_date = session.planned_date - if not planned_date: - return - if session.prefetched_immovables_by_date.get( - planned_date - ) and session.prefetched_remote_snapshots_by_date.get(planned_date): - self._apply_prefetched_calendar_immovables(session) - self._session_debug( - session, - "calendar_ensure_used_prefetch", - planned_date=planned_date, - ) - return - prefetch_task = asyncio.create_task( - self._prefetch_calendar_immovables(session, planned_date) - ) - self._session_debug( - session, - "calendar_ensure_wait_start", - planned_date=planned_date, - timeout_s=timeout_s, - ) - try: - await asyncio.wait_for( - asyncio.shield(prefetch_task), - timeout=timeout_s, - ) - except asyncio.TimeoutError: - logger.debug("Calendar prefetch timed out for %s", planned_date) - self._session_debug( - session, - "calendar_ensure_timeout", - planned_date=planned_date, - timeout_s=timeout_s, - ) - self._append_background_update_once( - session, - "Calendar fetch timed out; share fixed anchors manually or click Redo.", - ) - except Exception: - logger.debug("Calendar prefetch failed for %s", planned_date, exc_info=True) - if not prefetch_task.done(): - prefetch_task.cancel() - self._session_debug( - session, - "calendar_ensure_error", - planned_date=planned_date, - error="prefetch_task_failed", - ) - self._append_background_update_once( - session, - "Couldn't load calendar events yet; share fixed anchors manually or click Redo.", - ) - self._apply_prefetched_calendar_immovables(session) - self._session_debug( - session, - "calendar_ensure_done", - planned_date=planned_date, - immovable_count=len( - session.prefetched_immovables_by_date.get(planned_date) or [] - ), - ) - - def _apply_prefetched_calendar_immovables(self, session: Session) -> None: - """Apply prefetched immovables to frame facts when available.""" - planned_date = session.planned_date - if not planned_date: - return - prefetched = session.prefetched_immovables_by_date.get(planned_date) or [] - if not prefetched: - return - existing = session.frame_facts.get("immovables") - existing_rows = parse_model_list(Immovable, existing) - merged_rows: list[dict[str, str]] = [] - seen: set[tuple[str, str, str]] = set() - for source in (prefetched, existing_rows): - for row in parse_model_list(Immovable, source): - key = (row.title.strip(), row.start, row.end) - if key in seen: - continue - seen.add(key) - merged_rows.append( - { - "title": row.title, - "start": row.start, - "end": row.end, - } - ) - if not merged_rows: - return - session.frame_facts["immovables"] = merged_rows - self._session_debug( - session, - "calendar_anchors_applied", - planned_date=planned_date, - prefetched_count=len(prefetched), - existing_count=len(existing_rows), - merged_count=len(merged_rows), - ) - - def _build_commit_prompt_blocks(self, *, session: Session) -> SlackBlockMessage: - """Build the Stage 0 commit prompt Slack blocks.""" - tz_name = session.tz_name or "UTC" - planned_date = session.planned_date or "" - meta = encode_metadata( - { - "channel_id": session.channel_id, - "thread_ts": session.thread_ts, - "user_id": session.user_id, - "date": planned_date, - "tz": tz_name, - } - ) - return build_timebox_commit_prompt_message( - planned_date=planned_date, - tz_name=tz_name, - meta_value=meta, - ) - - async def _ensure_stage_agents(self) -> None: - """Ensure shared tooling (constraint search) is initialised. - - Stage-gating ``AssistantAgent`` instances are **not** cached here; each - call site creates a fresh agent via ``_build_one_shot_agent`` so that no - multi-turn message history accumulates across user turns. - """ - # Build the constraint search tool for optional non-Stage-1 lookups. - if self._constraint_search_tool is None: - self._constraint_search_tool = self._build_constraint_search_tool() - - def _build_one_shot_agent( - self, - name: str, - prompt: str, - out_type: Type, - *, - tools: list[FunctionTool] | None = None, - max_tool_iterations: int = 2, - structured_output: bool = True, - ) -> AssistantAgent: - """Create a fresh, stateless stage helper agent for a single LLM call. - - A new ``AssistantAgent`` is returned on every call so that no prior-turn - message history is carried into the next invocation. Only the shared - ``_model_client`` (a connection pool) is reused. - """ - if structured_output: - system_message = prompt - output_type: Type | None = out_type - else: - schema = TypeAdapter(out_type).json_schema() - schema_json = json.dumps( - schema, ensure_ascii=False, sort_keys=True, indent=2 - ) - system_message = ( - f"{prompt}\n\n" - "Return ONLY valid JSON matching this schema.\n" - f"JSON Schema:\n```json\n{schema_json}\n```" - ) - output_type = None - assert_strict_tools_for_structured_output( - tools=tools, - output_content_type=output_type, - agent_name=name, - ) - return AssistantAgent( - name=name, - model_client=self._model_client, - tools=tools, - output_content_type=output_type, - system_message=system_message, - reflect_on_tool_use=False, - max_tool_iterations=max_tool_iterations, - ) - - async def _run_stage_gate( - self, - *, - stage: TimeboxingStage, - user_message: str, - context: dict[str, Any], - ) -> StageGateOutput: - """Run the stage-gating LLM for the current stage. - - A fresh ``AssistantAgent`` is created on every call so no prior-turn - history accumulates in the stage agent's message buffer. - """ - await self._ensure_stage_agents() - optional_constraint_tools: list[FunctionTool] | None = ( - [self._constraint_search_tool] if self._constraint_search_tool else None - ) - _stage_cfg: dict[ - TimeboxingStage, tuple[str, Type, list[FunctionTool] | None, int] - ] = { - TimeboxingStage.COLLECT_CONSTRAINTS: ( - "StageCollectConstraints", - COLLECT_CONSTRAINTS_PROMPT, - optional_constraint_tools, - 2, - ), - TimeboxingStage.CAPTURE_INPUTS: ( - "StageCaptureInputs", - CAPTURE_INPUTS_PROMPT, - optional_constraint_tools, - 3, - ), - } - cfg = _stage_cfg.get(stage) - if cfg is None: - raise ValueError(f"Unsupported stage: {stage}") - agent_name, stage_prompt, stage_tools, max_iterations = cfg - agent = self._build_one_shot_agent( - agent_name, - stage_prompt, - StageGateOutput, - tools=stage_tools, - max_tool_iterations=max_iterations, - structured_output=True, - ) - - task = self._format_stage_gate_input(stage=stage, context=context) - try: - response = await with_timeout( - f"timeboxing:stage:{stage.value}", - agent.on_messages( - [TextMessage(content=task, source="user")], CancellationToken() - ), - timeout_s=TIMEBOXING_TIMEOUTS.stage_gate_s, - ) - except TimeoutError as exc: - error = f"Stage gate timeout for {stage.value}: {type(exc).__name__}" - logger.warning(error) - return self._build_stage_gate_fallback( - stage=stage, - context=context, - error=error, - missing="stage gate timeout", - question="This stage timed out. Reply in thread with `Redo` to retry, or share updates to continue.", - ) - except Exception as exc: - error = ( - f"Stage gate execution failed for {stage.value}: " - f"{type(exc).__name__}: {exc}" - ) - logger.error(error, exc_info=True) - return self._build_stage_gate_fallback( - stage=stage, - context=context, - error=error, - missing="stage retry required", - question="I hit an internal stage error. Reply in thread with `Redo` to retry this stage.", - ) - try: - return parse_chat_content(StageGateOutput, response) - except Exception as exc: - error = ( - f"Stage gate parse failed for {stage.value}: " - f"{type(exc).__name__}: {exc}" - ) - logger.error(error, exc_info=True) - return self._build_stage_gate_fallback( - stage=stage, - context=context, - error=error, - missing="stage retry required", - question="Reply in thread with `Redo` to retry this stage, or provide any updates and continue.", - ) - - @staticmethod - def _build_stage_gate_fallback( - *, - stage: TimeboxingStage, - context: dict[str, Any], - error: str, - missing: str, - question: str, - ) -> StageGateOutput: - """Build a safe gate result for recoverable stage failures.""" - fallback_facts = dict(context.get("facts") or {}) - fallback_facts["_stage_gate_error"] = error - return StageGateOutput( - stage_id=stage, - ready=False, - summary=[ - "I hit an internal stage-processing issue.", - "I kept your known facts and can continue once you confirm or retry.", - ], - missing=[missing], - question=question, - facts=fallback_facts, - ) - - @staticmethod - def _constraint_uid(constraint: ConstraintBase) -> str | None: - """Return a durable constraint UID when present.""" - hints = constraint.hints if isinstance(constraint.hints, dict) else {} - uid = hints.get("uid") - if isinstance(uid, str) and uid.strip(): - return uid.strip() - return None - - @staticmethod - def _constraint_matches_planned_day( - constraint: ConstraintBase, planned_day: date | None - ) -> bool: - """Return whether a profile/datespan constraint applies on the planned date.""" - if planned_day is None: - return True - if constraint.start_date and planned_day < constraint.start_date: - return False - if constraint.end_date and planned_day > constraint.end_date: - return False - days = list(constraint.days_of_week or []) - if days: - day_codes = { - value.value if isinstance(value, ConstraintDayOfWeek) else str(value) - for value in days - } - valid_day = ("MO", "TU", "WE", "TH", "FR", "SA", "SU")[planned_day.weekday()] - if valid_day not in day_codes: - return False - return True - - @staticmethod - def _parse_session_planned_day(session: Session) -> date | None: - """Parse planned date from session state when available.""" - planned_date = (session.planned_date or "").strip() - if not planned_date: - return None - try: - return date.fromisoformat(planned_date) - except Exception: - return None - - def _collect_stage_durable_constraints( - self, session: Session, *, stage: TimeboxingStage - ) -> list[Constraint]: - """Return durable constraints for a stage, excluding session-suppressed UIDs.""" - durable = session.durable_constraints_by_stage.get(stage.value, []) - out: list[Constraint] = [] - for constraint in durable or []: - uid = self._constraint_uid(constraint) - if uid and uid in session.suppressed_durable_uids: - continue - out.append(constraint) - if out or stage != TimeboxingStage.COLLECT_CONSTRAINTS: - return out - planned_day = self._parse_session_planned_day(session) - fallback: list[Constraint] = [] - for constraint in session.active_constraints or []: - if constraint.scope not in (ConstraintScope.PROFILE, ConstraintScope.DATESPAN): - continue - if constraint.status == ConstraintStatus.DECLINED: - continue - if not self._constraint_matches_planned_day(constraint, planned_day): - continue - uid = self._constraint_uid(constraint) - if uid and uid in session.suppressed_durable_uids: - continue - fallback.append(constraint) - if fallback: - self._session_debug( - session, - "collect_defaults_fallback_local", - count=len(fallback), - names=[ - (constraint.name or "").strip() - for constraint in fallback[:10] - if (constraint.name or "").strip() - ], - ) - return fallback - - @staticmethod - def _active_durable_stage_order(stage: TimeboxingStage | None) -> tuple[str, ...]: - """Return stage keys to merge into active constraints for the current turn.""" - if stage is None: - return () - match stage: - case TimeboxingStage.COLLECT_CONSTRAINTS | TimeboxingStage.CAPTURE_INPUTS: - return (TimeboxingStage.COLLECT_CONSTRAINTS.value,) - case TimeboxingStage.SKELETON: - return ( - TimeboxingStage.COLLECT_CONSTRAINTS.value, - TimeboxingStage.SKELETON.value, - ) - case TimeboxingStage.REFINE | TimeboxingStage.REVIEW_COMMIT: - return ( - TimeboxingStage.COLLECT_CONSTRAINTS.value, - TimeboxingStage.REFINE.value, - ) - case _: - return () - - def _extract_collect_default_value( - self, *, domain: str, constraint: Constraint - ) -> dict[str, Any] | None: - """Derive a deterministic default value for one collect-stage domain. - - Reads structured time bounds from ``hints["aspect_classification"]`` first, - then falls back to legacy explicit hint keys (``start_time``, ``wake_time``, - etc.). No regex scanning of free-form text is performed. - """ - hints = constraint.hints if isinstance(constraint.hints, dict) else {} - - # Prefer structured classification times (set by extraction LLM). - classification = ConstraintAspectClassification.from_hints(hints) - cls_start = classification.schedule_start if classification else None - cls_end = classification.schedule_end if classification else None - - # Legacy explicit hint keys as secondary fallback. - legacy_start = ( - str( - hints.get("start_time") - or hints.get("bed_time") - or hints.get("bedtime") - or "" - ).strip() - or None - ) - legacy_end = ( - str( - hints.get("end_time") - or hints.get("wake_time") - or hints.get("wake") - or "" - ).strip() - or None - ) - - if domain == "sleep_target": - start = cls_start or legacy_start or "" - end = cls_end or legacy_end or "" - if not start or not end: - return None - try: - start_dt = datetime.strptime(start, "%H:%M") - end_dt = datetime.strptime(end, "%H:%M") - except Exception: - return {"start": start, "end": end, "hours": None} - minutes = int((end_dt - start_dt).total_seconds() // 60) - if minutes <= 0: - minutes += 24 * 60 - hours = round(minutes / 60.0, 2) - return {"start": start, "end": end, "hours": hours} - - if domain == "work_window": - start = cls_start or legacy_start or "" - end = cls_end or legacy_end or "" - if not start or not end: - return None - return {"start": start, "end": end} - - return None - - def _classify_collect_default_domain(self, constraint: Constraint) -> str | None: - """Return the collect-stage domain key for a durable constraint. - - Reads ``hints["aspect_classification"]["frame_slot"]`` as assigned by - the extraction LLM. No keyword or regex scanning is performed. - Constraints that pre-date classification (no ``aspect_classification`` - in hints) return ``None`` and are ignored by the defaults pipeline. - """ - hints = constraint.hints if isinstance(constraint.hints, dict) else {} - classification = ConstraintAspectClassification.from_hints(hints) - return classification.frame_slot if classification is not None else None - - @staticmethod - def _collect_default_priority_key( - constraint: Constraint, - ) -> tuple[int, int, int, str]: - """Stable sorting key for selecting one default per domain.""" - status_rank = 0 if constraint.status == ConstraintStatus.LOCKED else 1 - necessity_map = _constraint_necessity_rank() - necessity_value: ConstraintNecessity | str = constraint.necessity - if necessity_value not in necessity_map and necessity_value is not None: - necessity_value = str(necessity_value).lower() - necessity_rank = necessity_map.get(necessity_value, 3) - scope_rank = 0 if constraint.scope == ConstraintScope.PROFILE else 1 - name = (constraint.name or "").strip().lower() - return (status_rank, necessity_rank, scope_rank, name) - - def _derive_collect_defaults_from_durable( - self, constraints: list[Constraint] - ) -> dict[str, Any]: - """Derive deterministic collect-stage defaults from durable constraints.""" - by_domain: dict[str, list[Constraint]] = {"sleep_target": [], "work_window": []} - for constraint in constraints or []: - domain = self._classify_collect_default_domain(constraint) - if domain in by_domain: - by_domain[domain].append(constraint) - - domain_values: dict[str, dict[str, Any]] = {} - domain_uids: dict[str, list[str]] = {} - domain_lines: dict[str, str] = {} - durable_applies: list[str] = [] - - for domain, items in by_domain.items(): - if not items: - continue - ordered = sorted(items, key=self._collect_default_priority_key) - chosen = ordered[0] - value = self._extract_collect_default_value( - domain=domain, constraint=chosen - ) - if value: - domain_values[domain] = value - uids = [ - uid for uid in (self._constraint_uid(item) for item in ordered) if uid - ] - if uids: - domain_uids[domain] = uids - line = (chosen.name or domain).strip() - description = (chosen.description or "").strip() - if description: - line = f"{line} β€” {description}" - domain_lines[domain] = line - durable_applies.append(line) - - return { - "domain_values": domain_values, - "domain_uids": domain_uids, - "domain_lines": domain_lines, - "durable_applies": durable_applies, - } - - @staticmethod - def _merge_unique_lines(base: list[str], extra: list[str]) -> list[str]: - """Return first-seen unique lines from two lists.""" - out: list[str] = [] - seen: set[str] = set() - for raw in (base or []) + (extra or []): - line = (raw or "").strip() - if not line or line in seen: - continue - seen.add(line) - out.append(line) - return out - - def _apply_collect_defaults_to_facts( - self, - *, - session: Session, - facts: dict[str, Any], - defaults: dict[str, Any], - ) -> list[str]: - """Apply deterministic collect defaults into stage facts when missing.""" - domain_values: dict[str, dict[str, Any]] = defaults.get("domain_values", {}) - domain_lines: dict[str, str] = defaults.get("domain_lines", {}) - applied_domains: list[str] = [] - - if domain_values.get("sleep_target") and not parse_model_optional( - SleepTarget, facts.get("sleep_target") - ): - facts["sleep_target"] = domain_values["sleep_target"] - applied_domains.append("sleep_target") - - if domain_values.get("work_window") and not parse_model_optional( - WorkWindow, facts.get("work_window") - ): - facts["work_window"] = domain_values["work_window"] - applied_domains.append("work_window") - - overview = dict(facts.get("constraint_overview") or {}) - durable_existing = list(overview.get("durable_applies") or []) - overview["durable_applies"] = self._merge_unique_lines( - durable_existing, list(defaults.get("durable_applies") or []) - ) - facts["constraint_overview"] = overview - - session.collect_defaults_applied = [ - domain_lines[d] - for d in applied_domains - if isinstance(domain_lines.get(d), str) - ] - facts["defaults_applied"] = list(session.collect_defaults_applied) - return applied_domains - - @staticmethod - def _facts_conflict_with_default( - *, domain: str, value: dict[str, Any], default: dict[str, Any] - ) -> bool: - """Return whether a fact value conflicts with a derived default for a domain.""" - if domain == "sleep_target": - cur = parse_model_optional(SleepTarget, value) - ref = parse_model_optional(SleepTarget, default) - if cur is None or ref is None: - return False - return (cur.start, cur.end, cur.hours) != (ref.start, ref.end, ref.hours) - if domain == "work_window": - cur = parse_model_optional(WorkWindow, value) - ref = parse_model_optional(WorkWindow, default) - if cur is None or ref is None: - return False - return (cur.start, cur.end) != (ref.start, ref.end) - return False - - def _normalize_collect_constraints_gate( - self, - *, - session: Session, - gate: StageGateOutput, - user_message: str, - ) -> StageGateOutput: - """Post-process Stage 1 gate output with deterministic defaults + suppression policy.""" - durable = self._collect_stage_durable_constraints( - session, stage=TimeboxingStage.COLLECT_CONSTRAINTS - ) - defaults = self._derive_collect_defaults_from_durable(durable) - facts = dict(session.frame_facts or {}) - facts.update(gate.facts or {}) - self._apply_collect_defaults_to_facts( - session=session, facts=facts, defaults=defaults - ) - - suppressed_domains: list[str] = [] - if user_message.strip(): - for domain, default_value in (defaults.get("domain_values") or {}).items(): - current_value = facts.get(domain) - if not isinstance(current_value, dict): - continue - if not self._facts_conflict_with_default( - domain=domain, - value=current_value, - default=default_value, - ): - continue - for uid in (defaults.get("domain_uids") or {}).get(domain, []): - if uid in session.suppressed_durable_uids: - continue - session.suppressed_durable_uids.add(uid) - suppressed_domains.append(domain) - - if suppressed_domains: - durable = self._collect_stage_durable_constraints( - session, stage=TimeboxingStage.COLLECT_CONSTRAINTS - ) - defaults = self._derive_collect_defaults_from_durable(durable) - self._apply_collect_defaults_to_facts( - session=session, - facts=facts, - defaults=defaults, - ) - unique_domains = sorted(set(suppressed_domains)) - override_line = ( - "Session override applied for " - + ", ".join(unique_domains) - + "; matching saved defaults were hidden for this session." - ) - gate.summary = self._merge_unique_lines( - list(gate.summary or []), [override_line] - ) - - sleep_known = ( - parse_model_optional(SleepTarget, facts.get("sleep_target")) is not None - ) - if sleep_known and gate.missing: - # Suppress missing items whose label matches an applied durable default label - # OR whose text mentions sleep/routine keywords. - # The keyword fallback is required until StageGateOutput.missing carries - # per-item domain tags so that LLM-generated missing labels can be matched - # to applied defaults without exact-string comparison. - # TODO(refactor): when StageGateOutput.missing carries per-item domain - # tags, remove the keyword fallback and use only the label-based filter. - applied_lines: set[str] = set(session.collect_defaults_applied or []) - - def _sleep_keyword(item: str) -> bool: - lower = item.lower() - return any( - kw in lower - for kw in ("sleep", "bedtime", "bed time", "wake", "routine") - ) - - gate.missing = [ - item - for item in gate.missing - if item not in applied_lines and not _sleep_keyword(item) - ] - if not gate.missing: - gate.ready = True - - defaults_applied = list(session.collect_defaults_applied or []) - collect_stage = TimeboxingStage.COLLECT_CONSTRAINTS.value - collect_loaded = collect_stage in session.durable_constraints_loaded_stages - collect_pending = collect_stage in session.pending_durable_stages - collect_failure = session.durable_constraints_failed_stages.get(collect_stage) - - if not collect_loaded: - gate.summary = [ - line - for line in list(gate.summary or []) - if "no existing durable constraints found" not in line.lower() - ] - if collect_pending: - gate.summary = self._merge_unique_lines( - gate.summary, - [ - "Saved constraints are still loading; I have not confirmed durable defaults yet." - ], - ) - elif collect_failure: - gate.summary = self._merge_unique_lines( - gate.summary, - [ - f"Saved constraints could not be confirmed yet: {collect_failure}" - ], - ) - else: - gate.summary = self._merge_unique_lines( - gate.summary, - [ - "Saved constraints are not loaded yet; I will keep checking in the background." - ], - ) - - if gate.ready and defaults_applied: - gate.summary = self._merge_unique_lines( - list(gate.summary or []), - [f"Using your saved defaults: {', '.join(defaults_applied)}."], - ) - gate.question = "Using your saved defaults. Reply to override for this session, or proceed." - - anchors = parse_model_list(Immovable, facts.get("immovables")) - if anchors: - anchor_preview = ", ".join( - f"{anchor.start}-{anchor.end} {anchor.title}" - for anchor in anchors[:3] - ) - if len(anchors) > 3: - anchor_preview = f"{anchor_preview}, +{len(anchors) - 3} more" - gate.summary = self._merge_unique_lines( - list(gate.summary or []), - [f"Calendar anchors loaded: {anchor_preview}."], - ) - - if isinstance(facts.get("_stage_gate_error"), str): - self._append_background_update_once(session, facts["_stage_gate_error"]) - - gate.facts = facts - return gate - - async def _refresh_collect_constraints_durable( - self, session: Session, *, reason: str - ) -> None: - """Force a targeted Stage 1 durable refresh after new user hints.""" - stage = TimeboxingStage.COLLECT_CONSTRAINTS - session.durable_constraints_loaded_stages.discard(stage.value) - task = self._queue_durable_prefetch_stage( - session=session, - stage=stage, - reason=reason, - ) - if not task: - return - try: - await asyncio.wait_for( - asyncio.shield(task), - timeout=TIMEBOXING_TIMEOUTS.durable_prefetch_wait_s, - ) - except asyncio.TimeoutError: - msg = ( - "Saved constraints refresh timed out while processing your latest " - "Stage 1 input. Continue now or use Redo to retry." - ) - session.durable_constraints_failed_stages[stage.value] = msg - self._append_background_update_once(session, msg) - logger.error(msg) - - def _format_stage_gate_input( - self, *, stage: TimeboxingStage, context: dict[str, Any] - ) -> str: - """Format stage-gate input with TOON tables for list data.""" - user_message = str(context.get("user_message") or "") - if stage == TimeboxingStage.COLLECT_CONSTRAINTS: - facts = dict(context.get("facts") or {}) - immovables = parse_model_list(Immovable, context.get("immovables")) - durable_constraints = list(context.get("durable_constraints") or []) - facts_json = json.dumps(facts, ensure_ascii=False, sort_keys=True) - immovables_toon = toon_encode( - name="immovables", - rows=immovables_rows(immovables), - fields=["title", "start", "end"], - ) - durable_toon = toon_encode( - name="durable_constraints", - rows=constraints_rows(durable_constraints), - fields=[ - "name", - "necessity", - "scope", - "status", - "source", - "description", - ], - ) - return ( - "The following lists are in TOON format: name[N]{keys}: defines the schema, and each line below is a record with values in that exact order.\n" - f"user_message: {user_message}\n" - f"facts_json: {facts_json}\n" - f"{immovables_toon}\n" - f"{durable_toon}\n" - ) - - if stage == TimeboxingStage.CAPTURE_INPUTS: - frame_facts = dict(context.get("frame_facts") or {}) - input_facts = dict(context.get("input_facts") or {}) - tasks = parse_model_list(TaskCandidate, input_facts.get("tasks")) - daily_one_thing = parse_model_optional( - DailyOneThing, input_facts.get("daily_one_thing") - ) - frame_facts_json = json.dumps( - frame_facts, ensure_ascii=False, sort_keys=True - ) - scrubbed_input = dict(input_facts) - scrubbed_input.pop("tasks", None) - scrubbed_input.pop("daily_one_thing", None) - input_facts_json = json.dumps( - scrubbed_input, ensure_ascii=False, sort_keys=True - ) - tasks_toon = toon_encode( - name="tasks", - rows=tasks_rows(tasks), - fields=["title", "block_count", "duration_min", "due", "importance"], - ) - daily_toon = toon_encode( - name="daily_one_thing", - rows=( - [ - { - "title": daily_one_thing.title, - "block_count": daily_one_thing.block_count or "", - "duration_min": daily_one_thing.duration_min or "", - } - ] - if daily_one_thing - else [] - ), - fields=["title", "block_count", "duration_min"], - ) - return ( - "The following lists are in TOON format: name[N]{keys}: defines the schema, and each line below is a record with values in that exact order.\n" - f"user_message: {user_message}\n" - f"frame_facts_json: {frame_facts_json}\n" - f"input_facts_json: {input_facts_json}\n" - f"{tasks_toon}\n" - f"{daily_toon}\n" - ) - - return json.dumps(context, ensure_ascii=False, sort_keys=True) - - def _build_collect_constraints_context( - self, session: Session, *, user_message: str - ) -> dict[str, Any]: - """Build the injected context payload for the CollectConstraints stage.""" - self._refresh_temporal_facts(session) - self._apply_prefetched_calendar_immovables(session) - normalized = parse_model_list(Immovable, session.frame_facts.get("immovables")) - planned_date = (session.planned_date or "").strip() - self._session_debug( - session, - "collect_context_calendar_anchors", - planned_date=planned_date, - immovable_count=len(normalized), - prefetched_count=len( - session.prefetched_immovables_by_date.get(planned_date) or [] - ) - if planned_date - else 0, - pending_prefetch=session.pending_calendar_prefetch, - ) - durable = self._collect_stage_durable_constraints( - session, stage=TimeboxingStage.COLLECT_CONSTRAINTS - ) - facts = dict(session.frame_facts or {}) - defaults = self._derive_collect_defaults_from_durable(list(durable or [])) - self._apply_collect_defaults_to_facts( - session=session, - facts=facts, - defaults=defaults, - ) - return CollectConstraintsContext( - user_message=user_message, - facts=facts, - immovables=normalized, - durable_constraints=list(durable or []), - ).model_dump(mode="json") - - def _build_capture_inputs_context( - self, session: Session, *, user_message: str - ) -> dict[str, Any]: - """Build the injected context payload for the CaptureInputs stage.""" - prefetch_scope = self._capture_inputs_prefetch_scope(session) - prefetched = ( - list(session.prefetched_pending_tasks or []) - if prefetch_scope["allow_prefetch"] - else [] - ) - input_facts = TaskMarshallingCapability.merge_prefetched_tasks( - input_facts=dict(session.input_facts or {}), - prefetched=prefetched, - ) - task_candidates = parse_model_list(TaskCandidate, input_facts.get("tasks")) - filtered_out = max(0, len(session.prefetched_pending_tasks or []) - len(prefetched)) - self._session_debug( - session, - "capture_inputs_task_context", - prefetched_count=len(session.prefetched_pending_tasks or []), - merged_task_count=len(task_candidates), - prefetch_policy=str(prefetch_scope["policy"]), - prefetch_scope_signals=list(prefetch_scope["scope_signals"]), - prefetch_scope_reasons=list(prefetch_scope["reasons"]), - prefetched_filtered_out_count=filtered_out, - top_titles=[ - (task.title or "").strip() - for task in task_candidates[:8] - if (task.title or "").strip() - ], - ) - return CaptureInputsContext( - user_message=user_message, - frame_facts=dict(session.frame_facts or {}), - input_facts=input_facts, - ).model_dump(mode="json") - - def _capture_inputs_prefetch_scope(self, session: Session) -> dict[str, Any]: - """Decide whether task-marshalling prefetch should be injected in Stage 2.""" - explicit_tasks = parse_model_list( - TaskCandidate, dict(session.input_facts or {}).get("tasks") - ) - if explicit_tasks: - return { - "allow_prefetch": False, - "policy": "explicit_tasks_present", - "scope_signals": [], - "reasons": ["user_provided_tasks"], - } - - scope_signals: set[str] = set() - for constraint in list(session.active_constraints or []): - aspect_id = self._constraint_aspect_id(constraint) - if aspect_id: - scope_signals.add(aspect_id) - - reasons: list[str] = [] - suppress_signals = {"gtd_admin_exclusion", "daily_one_thing"} - matched = sorted(signal for signal in scope_signals if signal in suppress_signals) - if matched: - reasons.append("scope_first_suppress_prefetch") - reasons.extend(matched) - return { - "allow_prefetch": False, - "policy": "scope_first_suppress_prefetch", - "scope_signals": sorted(scope_signals), - "reasons": reasons, - } - - return { - "allow_prefetch": True, - "policy": "default_prefetch", - "scope_signals": sorted(scope_signals), - "reasons": [], - } - - def _quality_snapshot_for_prompt(self, session: Session) -> dict[str, Any]: - """Return previously captured quality facts for refine-stage prompt context.""" - if session.last_quality_level is None or not session.last_quality_label: - return {} - payload: dict[str, Any] = { - "quality_level": session.last_quality_level, - "quality_label": session.last_quality_label, - } - if session.last_quality_next_step: - payload["next_suggestion"] = session.last_quality_next_step - return payload - - @staticmethod - def _remaining_graph_turn_budget_s(session: Session) -> float | None: - """Return remaining graph-turn budget in seconds for the current turn.""" - deadline = session.graph_turn_deadline_monotonic - if deadline is None: - return None - return max(0.0, deadline - perf_counter()) - - def _build_refine_budget_fastpath_gate( - self, *, session: Session - ) -> StageGateOutput: - """Build a deterministic refine gate when turn budget is nearly exhausted.""" - summary = [ - "Patch applied locally and staged for review.", - "Skipping extra quality analysis this turn to avoid a timeout.", - ] - snapshot = self._quality_snapshot_for_prompt(session) - if snapshot: - level = snapshot.get("quality_level") - label = snapshot.get("quality_label") - if isinstance(level, int) and isinstance(label, str) and label.strip(): - summary.append(f"Last known quality: {label.strip()} ({level}/4).") - return StageGateOutput( - stage_id=TimeboxingStage.REFINE, - ready=True, - summary=summary, - missing=[], - question=( - "Reply with edits, or say `commit now` to submit this staged patch to calendar." - ), - facts=snapshot, - ) - - async def _run_refine_quality_assessment( - self, *, timebox: Timebox - ) -> RefineQualityFacts: - """Ask a typed LLM helper to assess refine-stage quality facts.""" - events_toon = toon_encode( - name="events", - rows=timebox_events_rows(timebox.events or []), - fields=["type", "summary", "ST", "ET", "DT", "AP", "location"], - ) - quality_prompt = ( - "You assess refine-stage planning quality from a timeboxed schedule.\n" - "Return STRICT JSON matching RefineQualityFacts.\n" - f"{QUALITY_RUBRIC_PROMPT}\n" - "Guidance:\n" - "- Base the score on schedule structure and flow quality.\n" - "- missing_for_next should be concrete and actionable.\n" - "- next_suggestion should be one practical next refinement step.\n" - ) - quality_agent = AssistantAgent( - name="StageRefineQualityAssessor", - model_client=self._model_client, - tools=None, - output_content_type=RefineQualityFacts, - system_message=quality_prompt, - reflect_on_tool_use=False, - max_tool_iterations=2, - ) - response = await with_timeout( - "timeboxing:summary:RefineQuality", - quality_agent.on_messages( - [TextMessage(content=events_toon, source="user")], - CancellationToken(), - ), - timeout_s=TIMEBOXING_TIMEOUTS.summary_s, - ) - return parse_chat_content(RefineQualityFacts, response) - - async def _enrich_refine_quality_feedback( - self, - *, - session: Session, - gate: StageGateOutput, - timebox: Timebox, - ) -> StageGateOutput: - """Ensure refine-stage gate contains typed quality facts and session carry state.""" - quality = parse_model_optional(RefineQualityFacts, gate.facts) - if quality is None: - quality = await self._run_refine_quality_assessment(timebox=timebox) - gate.facts = quality.model_dump(mode="json") - session.last_quality_level = quality.quality_level - session.last_quality_label = quality.quality_label - session.last_quality_next_step = quality.next_suggestion - quality_line = f"Quality: {quality.quality_label} ({quality.quality_level}/4)." - if quality_line not in gate.summary: - gate.summary.append(quality_line) - if quality.quality_level < 4: - next_line = f"Next upgrade: {quality.next_suggestion}" - if next_line not in gate.summary: - gate.summary.append(next_line) - gate.question = ( - f"{gate.question or 'Want another refine pass?'} " - f"Next suggested step: {quality.next_suggestion}" - ) - return gate - - async def _run_timebox_summary( - self, - *, - stage: TimeboxingStage, - timebox: Timebox, - session: Session | None = None, - allow_quality_enrichment: bool = True, - ) -> StageGateOutput: - """Generate a summary for a timebox draft. - - A fresh agent is built on each call so the LLM only sees the - current timebox, never prior-turn message history. - """ - summary_agent = self._build_one_shot_agent( - "StageTimeboxSummary", - TIMEBOX_SUMMARY_PROMPT, - StageGateOutput, - structured_output=False, - ) - events_toon = toon_encode( - name="events", - rows=timebox_events_rows(timebox.events or []), - fields=["type", "summary", "ST", "ET", "DT", "AP", "location"], - ) - payload = f"stage_id: {stage.value}\n{events_toon}\n" - response = await with_timeout( - f"timeboxing:summary:{stage.value}", - summary_agent.on_messages( - [TextMessage(content=payload, source="user")], - CancellationToken(), - ), - timeout_s=TIMEBOXING_TIMEOUTS.summary_s, - ) - gate = parse_chat_content(StageGateOutput, response) - if ( - stage == TimeboxingStage.REFINE - and session is not None - and allow_quality_enrichment - ): - gate = await self._enrich_refine_quality_feedback( - session=session, - gate=gate, - timebox=timebox, - ) - elif stage == TimeboxingStage.REFINE and session is not None: - snapshot = self._quality_snapshot_for_prompt(session) - if snapshot: - gate.facts = {**snapshot, **(gate.facts or {})} - return gate - - async def _run_review_commit(self, *, timebox: Timebox) -> StageGateOutput: - """Generate the final review/commit response. - - A fresh agent is built on each call so no prior-turn history is sent. - """ - review_agent = self._build_one_shot_agent( - "StageReviewCommit", - REVIEW_COMMIT_PROMPT, - StageGateOutput, - structured_output=False, - ) - events_toon = toon_encode( - name="events", - rows=timebox_events_rows(timebox.events or []), - fields=["type", "summary", "ST", "ET", "DT", "AP", "location"], - ) - payload = f"{events_toon}\n" - response = await with_timeout( - "timeboxing:review-commit", - review_agent.on_messages( - [TextMessage(content=payload, source="user")], - CancellationToken(), - ), - timeout_s=TIMEBOXING_TIMEOUTS.review_commit_s, - ) - return parse_chat_content(StageGateOutput, response) - - async def _run_skeleton_draft( - self, session: Session - ) -> tuple[None, str, TBPlan | None]: - """Draft a Stage 3 markdown overview and a TBPlan draft.""" - await self._ensure_stage_agents() - context = await self._build_skeleton_context(session) - try: - markdown_overview = await self._run_skeleton_overview_markdown( - context=context - ) - session.skeleton_overview_markdown = markdown_overview - seed_plan = self._build_skeleton_seed_plan(session) - return None, markdown_overview, seed_plan - except Exception: - logger.warning( - "Skeleton overview draft failed; using deterministic fallback.", - exc_info=True, - ) - session.background_updates.append( - "Skeleton overview draft failed; using deterministic fallback." - ) - fallback_plan = self._build_skeleton_seed_plan(session) - fallback_markdown = self._tb_plan_overview_markdown(fallback_plan) - session.skeleton_overview_markdown = fallback_markdown - return None, fallback_markdown, fallback_plan - - def _tb_plan_overview_markdown(self, plan: TBPlan) -> str: - """Render concise Stage 3 markdown from TBPlan.""" - lines = ["## Day Overview"] - try: - resolved = plan.resolve_times() - except Exception: - resolved = [] - if not resolved: - lines.append("- No blocks drafted yet.") - return "\n".join(lines) - - sections: dict[str, list[str]] = { - "Night": [], - "Morning": [], - "Midday": [], - "Afternoon": [], - "Evening": [], - } - for index, event in enumerate(resolved): - title = str(event.get("n") or "Untitled") - start_time = event.get("start_time") - end_time = event.get("end_time") - placement = plan.events[index].p.a if index < len(plan.events) else "" - anchored = placement in {"fs", "fw"} - if anchored and start_time is not None and end_time is not None: - entry = ( - f"- {start_time.isoformat(timespec='minutes')}-" - f"{end_time.isoformat(timespec='minutes')} **{title}**" - ) - else: - duration = self._coarse_duration_label( - start_time=start_time, - end_time=end_time, - ) - entry = f"- **{title}** β€” {duration}" if duration else f"- **{title}**" - - if start_time is None: - bucket = "Morning" - else: - hour = start_time.hour - if hour < 6: - bucket = "Night" - elif hour < 12: - bucket = "Morning" - elif hour < 14: - bucket = "Midday" - elif hour < 18: - bucket = "Afternoon" - elif hour < 22: - bucket = "Evening" - else: - bucket = "Night" - sections[bucket].append(entry) - - for heading in ("Night", "Morning", "Midday", "Afternoon", "Evening"): - entries = sections[heading] - if not entries: - continue - lines.append(f"### {heading}") - lines.extend(entries) - return "\n".join(lines) - - def _coarse_duration_label( - self, *, start_time: time | None, end_time: time | None - ) -> str: - """Return a rough, glanceable duration label for flexible Stage 3 blocks.""" - if start_time is None or end_time is None: - return "" - start_dt = datetime.combine(date.today(), start_time) - end_dt = datetime.combine(date.today(), end_time) - minutes = int((end_dt - start_dt).total_seconds() // 60) - if minutes <= 0: - return "" - hours, remainder = divmod(minutes, 60) - if hours and remainder: - return f"~{hours}h{remainder}m" - if hours: - return f"~{hours}h" - return f"~{minutes}m" - - def _patcher_context_payload( - self, - *, - session: Session, - stage: str, - extra: dict[str, Any] | None = None, - ) -> dict[str, Any]: - """Build structured context injected into patcher requests.""" - frame = session.frame_facts or {} - payload: dict[str, Any] = { - "stage": stage, - "planned_date": session.planned_date, - "timezone": session.tz_name, - "frame_facts": { - "work_window": frame.get("work_window"), - "sleep_target": frame.get("sleep_target"), - "immovables": frame.get("immovables"), - }, - "input_facts": session.input_facts or {}, - } - if extra: - payload["extra"] = extra - return payload - - def _compose_patcher_message( - self, - *, - base_message: str, - session: Session, - stage: str, - extra: dict[str, Any] | None = None, - ) -> str: - """Attach structured planning context to patcher instructions.""" - if stage != TimeboxingStage.REFINE.value: - raise ValueError( - "Patcher messages are restricted to Stage 4 Refine. " - f"Received stage={stage!r}." - ) - context_json = json.dumps( - self._patcher_context_payload(session=session, stage=stage, extra=extra), - ensure_ascii=False, - default=str, - sort_keys=True, - ) - return ( - f"{base_message.strip()}\n\n" - "Planning context:\n" - f"```json\n{context_json}\n```" - ) - - async def _run_skeleton_overview_markdown(self, *, context: SkeletonContext) -> str: - """Generate the Stage 3 markdown overview for Slack rendering.""" - system_prompt = render_skeleton_draft_system_prompt(context=context) - draft_agent = AssistantAgent( - name="StageDraftSkeletonOverview", - model_client=self._draft_model_client, - tools=None, - system_message=system_prompt, - reflect_on_tool_use=False, - max_tool_iterations=2, - ) - response = await with_timeout( - "timeboxing:skeleton-overview", - draft_agent.on_messages( - [ - TextMessage( - content="Draft the Stage 3 day overview in markdown now.", - source="user", - ) - ], - CancellationToken(), - ), - timeout_s=TIMEBOXING_TIMEOUTS.skeleton_draft_s, - ) - content = getattr(getattr(response, "chat_message", None), "content", None) - if isinstance(content, str) and content.strip(): - return content.strip() - raise ValueError("Skeleton overview response did not contain markdown text.") - - def _build_skeleton_seed_plan(self, session: Session) -> TBPlan: - """Build the seed plan that the patcher expands into a full skeleton.""" - planning_date = self._resolve_planning_date(session) - tz_name = session.tz_name or "UTC" - immovables = self._normalize_calendar_events( - session.frame_facts.get("immovables") - ) - seed_events = immovables or [self._build_focus_block_event(timezone=tz_name)] - seed_plan = self._tb_plan_from_calendar_events( - events=seed_events, - planning_date=planning_date, - tz_name=tz_name, - ) - if seed_plan.events: - return seed_plan - if immovables: - fallback = self._build_focus_block_event(timezone=tz_name) - return self._tb_plan_from_calendar_events( - events=[fallback], - planning_date=planning_date, - tz_name=tz_name, - ) - return seed_plan - - def _tb_plan_from_calendar_events( - self, - *, - events: list[CalendarEvent], - planning_date: date, - tz_name: str, - ) -> TBPlan: - """Convert calendar events into a TBPlan while skipping unmappable entries.""" - tb_events: list[TBEvent] = [] - skipped: list[str] = [] - for event in events: - seed_timebox = Timebox.model_construct( - events=[event], - date=planning_date, - timezone=tz_name, - ) - try: - candidate = timebox_to_tb_plan(seed_timebox, validate=False) - except Exception as exc: - label = str( - getattr(event, "summary", None) - or getattr(event, "eventId", None) - or "event" - ).strip() - skipped.append(f"{label}: {str(exc) or type(exc).__name__}") - continue - if candidate.events: - tb_events.extend(candidate.events) - if skipped: - logger.warning( - "Skipped %s unmappable calendar event(s) while building TBPlan: %s", - len(skipped), - "; ".join(skipped[:5]), - ) - return TBPlan.model_construct(events=tb_events, date=planning_date, tz=tz_name) - - def _fallback_skeleton_markdown(self, fallback: Timebox) -> str: - """Render a deterministic markdown overview for fallback drafts.""" - lines = ["## Day Overview"] - if not fallback.events: - lines.append("- No events drafted yet.") - return "\n".join(lines) - lines.append("### Planned") - for event in fallback.events: - start = ( - event.start_time.isoformat(timespec="minutes") - if event.start_time - else "" - ) - end = event.end_time.isoformat(timespec="minutes") if event.end_time else "" - if start and end: - lines.append(f"- {start}-{end} {event.summary}") - else: - lines.append(f"- {event.summary}") - return "\n".join(lines) - - def _skeleton_pregeneration_fingerprint(self, session: Session) -> str: - """Build a deterministic fingerprint for current skeleton draft inputs.""" - payload = { - "planned_date": session.planned_date or "", - "tz_name": session.tz_name or "UTC", - "frame_facts": session.frame_facts or {}, - "input_facts": session.input_facts or {}, - "constraints": [ - c.model_dump(mode="json") - for c in ( - session.durable_constraints_by_stage.get( - TimeboxingStage.SKELETON.value, [] - ) - or [] - ) - ], - } - encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True) - return hashlib.sha256(encoded.encode("utf-8")).hexdigest() - - def _can_pre_generate_skeleton(self, session: Session) -> bool: - """Return whether Stage 2 has enough context to pre-generate skeleton.""" - immovables = parse_model_list(Immovable, session.frame_facts.get("immovables")) - has_immovables = bool(immovables) - durable = session.durable_constraints_by_stage.get( - TimeboxingStage.SKELETON.value, [] - ) - has_constraints = bool(durable or session.active_constraints) - return has_immovables and has_constraints - - def _queue_skeleton_pre_generation(self, session: Session) -> None: - """Queue a background skeleton draft during Stage 2.""" - if not self._can_pre_generate_skeleton(session): - return - fingerprint = self._skeleton_pregeneration_fingerprint(session) - if ( - session.pre_generated_skeleton_plan is not None - and session.pre_generated_skeleton_fingerprint == fingerprint - ): - return - active_task = session.pre_generated_skeleton_task - if active_task and not active_task.done(): - if session.pre_generated_skeleton_fingerprint == fingerprint: - return - active_task.cancel() - session.pre_generated_skeleton = None - session.pre_generated_skeleton_plan = None - session.pre_generated_skeleton_markdown = None - session.pre_generated_skeleton_fingerprint = fingerprint - session.pending_skeleton_pre_generation = True - - async def _background() -> None: - """Run skeleton pre-generation without blocking user responses.""" - try: - draft, markdown, drafted_plan = await self._run_skeleton_draft(session) - if ( - session.pre_generated_skeleton_fingerprint == fingerprint - and drafted_plan is not None - ): - session.pre_generated_skeleton = draft - session.pre_generated_skeleton_plan = drafted_plan - session.pre_generated_skeleton_markdown = markdown - session.background_updates.append( - "Prepared a skeleton draft in the background." - ) - except asyncio.CancelledError: - logger.debug("Skeleton pre-generation task canceled.") - except Exception: - logger.debug("Skeleton pre-generation failed.", exc_info=True) - finally: - if session.pre_generated_skeleton_fingerprint == fingerprint: - session.pending_skeleton_pre_generation = False - session.pre_generated_skeleton_task = None - - session.pre_generated_skeleton_task = asyncio.create_task(_background()) - - async def _consume_pre_generated_skeleton( - self, session: Session - ) -> tuple[None, str, TBPlan | None]: - """Return a pre-generated skeleton when valid, else draft synchronously.""" - fingerprint = self._skeleton_pregeneration_fingerprint(session) - active_task = session.pre_generated_skeleton_task - if ( - active_task - and not active_task.done() - and session.pre_generated_skeleton_fingerprint == fingerprint - ): - try: - await asyncio.wait_for( - asyncio.shield(active_task), - timeout=TIMEBOXING_TIMEOUTS.skeleton_draft_s, - ) - except asyncio.TimeoutError: - logger.warning( - "Timed out waiting for in-flight skeleton pre-generation; " - "falling back to synchronous draft." - ) - except asyncio.CancelledError: - logger.debug( - "Skeleton pre-generation task was canceled before consume." - ) - except Exception: - logger.debug( - "Skeleton pre-generation task failed before consume.", - exc_info=True, - ) - if ( - session.pre_generated_skeleton_plan is not None - and session.pre_generated_skeleton_fingerprint == fingerprint - ): - drafted_plan = session.pre_generated_skeleton_plan - markdown = ( - session.pre_generated_skeleton_markdown - or self._tb_plan_overview_markdown(drafted_plan) - ) - session.pre_generated_skeleton = None - session.pre_generated_skeleton_plan = None - session.pre_generated_skeleton_markdown = None - session.pre_generated_skeleton_task = None - session.pending_skeleton_pre_generation = False - return None, markdown, drafted_plan - return await self._run_skeleton_draft(session) - - def _ensure_refine_plan_state(self, session: Session) -> RefinePreflight: - """Ensure Stage 4 has a TBPlan and remote baseline snapshot ready for sync. - - Returns: - Structured preflight diagnostics used to seed repair patching. - """ - result = RefinePreflight() - if session.timebox is None and session.tb_plan is None: - return result - if session.tb_plan is None: - try: - session.tb_plan = timebox_to_tb_plan(session.timebox) - except Exception as exc: - issue = str(exc).strip() or type(exc).__name__ - result.plan_issues.append(f"timebox_to_tb_plan: {issue}") - session.tb_plan = timebox_to_tb_plan(session.timebox, validate=False) - self._session_debug( - session, - "refine_plan_prepared_unvalidated", - source="timebox_to_tb_plan", - issue=issue, - event_count=len(session.tb_plan.events), - ) - self._session_debug( - session, - "refine_plan_prepared", - source="timebox_to_tb_plan", - event_count=len(session.tb_plan.events), - ) - if session.base_snapshot is None: - try: - session.base_snapshot = self._build_remote_snapshot_plan(session) - self._session_debug( - session, - "refine_base_snapshot_prepared", - source="calendar_immovables", - event_count=len(session.base_snapshot.events), - ) - except Exception as exc: - issue = str(exc).strip() or type(exc).__name__ - result.snapshot_issues.append(f"remote_snapshot: {issue}") - self._session_debug( - session, - "refine_base_snapshot_failed", - source="calendar_immovables", - issue=issue, - ) - return result - - def _build_timeboxing_action_value(self, session: Session) -> str: - """Encode Slack metadata for timeboxing submit/undo buttons.""" - return encode_metadata( - { - "channel_id": session.channel_id, - "thread_ts": session.thread_ts, - "user_id": session.user_id, - } - ) - - def _build_stage_action_value(self, session: Session) -> str: - """Encode Slack metadata for deterministic stage-control buttons.""" - return encode_metadata( - { - "channel_id": session.channel_id, - "thread_ts": session.thread_ts, - "user_id": session.user_id, - } - ) - - def _render_stage_action_blocks(self, *, session: Session) -> list[dict[str, Any]]: - """Render deterministic stage-control actions for the current session stage.""" - if session.completed or session.thread_state in {"done", "canceled"}: - return [] - has_refine_undo = ( - session.stage == TimeboxingStage.REFINE - and session.last_refine_undo_tb_plan is not None - ) - can_go_back = session.stage != TimeboxingStage.COLLECT_CONSTRAINTS - can_proceed = ( - session.stage != TimeboxingStage.REVIEW_COMMIT and session.stage_ready - and not has_refine_undo - ) - meta_value = self._build_stage_action_value(session) - return [ - build_stage_actions_block( - meta_value=meta_value, - can_proceed=can_proceed, - can_go_back=can_go_back, - redo_label="Undo last update" if has_refine_undo else "Redo", - include_cancel=True, - ) - ] - - def _render_constraints_preview_blocks( - self, - *, - session: Session, - limit: int = 3, - ) -> list[dict[str, Any]]: - """Render a compact constraint preview with a modal entrypoint for the full list.""" - constraints = list(session.active_constraints or []) - if not constraints: - return [] - ranked = sorted(constraints, key=_constraint_priority) - summary_line = _constraint_count_summary_line( - session=session, - review_count=len(ranked), - include_newly_extracted_label="Newly extracted", - ) - blocks: list[dict[str, Any]] = [ - {"type": "divider"}, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ( - f"*Constraints*\n" - f"{summary_line} " - f"Showing the top {min(limit, len(ranked))} of {len(ranked)}." - ), - }, - }, - ] - blocks.extend( - build_constraint_row_blocks( - ranked, - thread_ts=session.thread_ts, - user_id=session.user_id, - limit=limit, - button_text="Deny / Edit", - ) - ) - if len(ranked) > limit: - blocks.append( - build_constraint_review_all_action_block( - thread_ts=session.thread_ts, - user_id=session.user_id, - count=len(ranked), - ) - ) - return blocks - - def _event_key_from_summary_and_start( - self, *, summary: str, start: str, tz_name: str - ) -> str | None: - """Build the canonical ``summary|start_time`` key used by sync mapping.""" - try: - tz = ZoneInfo(tz_name or "UTC") - parsed = date_parser.isoparse(start) - if parsed.tzinfo: - start_time = parsed.astimezone(tz).time().replace(tzinfo=None) - else: - start_time = parsed.time() - except Exception: - return None - return f"{summary}|{start_time.isoformat()}" - - def _update_event_id_map_after_submit( - self, - *, - session: Session, - transaction: SyncTransaction, - ) -> Dict[str, str]: - """Update ``session.event_id_map`` after a submit transaction.""" - previous = dict(session.event_id_map) - op_key_to_id: Dict[str, str] = {} - for index, op in enumerate(transaction.ops): - if transaction.results: - result = ( - transaction.results[index] - if index < len(transaction.results) - else {} - ) - if not bool(result.get("ok", False)): - continue - payload = op.after_payload or {} - summary = str(payload.get("summary") or "").strip() - start = str(payload.get("start") or "").strip() - event_id = str(payload.get("eventId") or op.gcal_event_id or "").strip() - if not (summary and start and event_id): - continue - key = self._event_key_from_summary_and_start( - summary=summary, - start=start, - tz_name=session.tz_name, - ) - if key: - op_key_to_id[key] = event_id - updated: Dict[str, str] = { - key: value - for key, value in previous.items() - if not value.startswith(FFTB_PREFIX) - } - if session.tb_plan is None: - return updated - for resolved in session.tb_plan.resolve_times(): - key = f"{resolved['n']}|{resolved['start_time'].isoformat()}" - event_id = op_key_to_id.get(key) or previous.get(key) - if event_id: - updated[key] = event_id - return updated - - async def _refresh_remote_baseline_after_sync(self, session: Session) -> None: - """Refresh baseline snapshot/event IDs from live calendar after sync.""" - fallback_plan = ( - session.tb_plan.model_copy(deep=True) if session.tb_plan else None - ) - fallback_ids = list(session.remote_event_ids_by_index or []) - planned_date = ( - session.planned_date or self._resolve_planning_date(session).isoformat() - ) - try: - await self._prefetch_calendar_immovables( - session, - planned_date, - force_refresh=True, - ) - session.base_snapshot = self._build_remote_snapshot_plan(session) - except Exception as exc: - logger.warning( - "Unable to refresh remote baseline after sync; using local fallback (%s).", - exc, - ) - if fallback_plan is not None: - session.base_snapshot = fallback_plan - session.remote_event_ids_by_index = fallback_ids - - async def _refresh_remote_baseline_before_submit( - self, - *, - session: Session, - context: str, - ) -> bool: - """Refresh remote snapshot before submit; fail closed when unavailable. - - Returns ``True`` when the baseline is fresh enough to safely plan sync ops. - """ - planned_date = (session.planned_date or "").strip() - if not planned_date: - # Unit tests and degenerate sessions may not have a planned date. - # Keep existing behavior in that case. - return True - - self._session_debug( - session, - "calendar_submit_refresh_start", - context=context, - planned_date=planned_date, - ) - try: - await self._prefetch_calendar_immovables( - session, - planned_date, - force_refresh=True, - ) - except Exception as exc: - self._session_debug( - session, - "calendar_submit_refresh_error", - context=context, - planned_date=planned_date, - error_type=type(exc).__name__, - error=str(exc)[:1000], - ) - return False - - if planned_date not in session.prefetched_remote_snapshots_by_date: - self._session_debug( - session, - "calendar_submit_refresh_missing_snapshot", - context=context, - planned_date=planned_date, - ) - return False - - session.base_snapshot = self._build_remote_snapshot_plan(session) - self._session_debug( - session, - "calendar_submit_refresh_done", - context=context, - planned_date=planned_date, - remote_events=len(session.base_snapshot.events), - event_id_map_size=len(session.event_id_map), - remote_identity_count=len(session.remote_event_ids_by_index), - ) - return True - - async def _ensure_submit_baseline_ready( - self, - *, - session: Session, - context: str, - ) -> SubmitBaselineGuard: - """Return deterministic guard state for submit-time baseline prerequisites.""" - refresh_ok = await self._refresh_remote_baseline_before_submit( - session=session, - context=context, - ) - return evaluate_submit_baseline_guard( - refresh_ok=refresh_ok, - has_base_snapshot=session.base_snapshot is not None, - ) - - def _render_submit_prompt_blocks( - self, *, session: Session, text: str - ) -> list[dict[str, Any]]: - """Render Slack blocks for Stage 5 confirm/cancel flow.""" - _ = text - action_value = self._build_timeboxing_action_value(session) - return [build_review_submit_actions_block(meta_value=action_value)] - - def _render_markdown_summary_blocks(self, *, text: str) -> list[dict[str, Any]]: - """Render a markdown summary block for Slack output.""" - return [build_markdown_block(text=text)] - - def _render_submit_result_blocks( - self, *, session: Session, text: str, include_undo: bool - ) -> list[dict[str, Any]]: - """Render Slack blocks for post-submit result messages.""" - blocks: list[dict[str, Any]] = [build_text_section_block(text=text)] - if include_undo: - action_value = self._build_timeboxing_action_value(session) - blocks.append(build_undo_submit_actions_block(meta_value=action_value)) - return blocks - - def _build_remote_snapshot_plan(self, session: Session) -> TBPlan: - """Build the calendar baseline snapshot used by the sync engine.""" - planning_date = self._resolve_planning_date(session) - tz_name = session.tz_name or "UTC" - planned_date_key = planning_date.isoformat() - prefetched = session.prefetched_remote_snapshots_by_date.get(planned_date_key) - if prefetched is not None: - prefetched_map = ( - session.prefetched_event_id_maps_by_date.get(planned_date_key) or {} - ) - prefetched_ids = ( - session.prefetched_remote_event_ids_by_date.get(planned_date_key) or [] - ) - session.event_id_map = dict(prefetched_map) - session.remote_event_ids_by_index = list(prefetched_ids) - return prefetched.model_copy(deep=True) - - immovables = self._normalize_calendar_events( - session.frame_facts.get("immovables") - ) - session.remote_event_ids_by_index = [] - return self._tb_plan_from_calendar_events( - events=immovables, - planning_date=planning_date, - tz_name=tz_name, - ) - - def _summarize_sync_transaction(self, tx: SyncTransaction) -> CalendarSyncOutcome: - """Convert a sync transaction into a stable user-facing outcome payload.""" - created = 0 - updated = 0 - deleted = 0 - failed = 0 - succeeded = 0 - failed_details: list[dict[str, str]] = [] - for index, op in enumerate(tx.ops): - result = tx.results[index] if index < len(tx.results) else {} - ok = bool(result.get("ok", False)) - if ok: - succeeded += 1 - if op.op_type.value == "create": - created += 1 - elif op.op_type.value == "update": - updated += 1 - elif op.op_type.value == "delete": - deleted += 1 - else: - failed += 1 - failure_message = str( - result.get("error") - or result.get("content") - or "unknown sync failure" - ).strip() - if len(failure_message) > 300: - failure_message = f"{failure_message[:297]}..." - failed_details.append( - { - "index": str(index), - "op": op.op_type.value, - "tool": op.tool_name, - "event_id": op.gcal_event_id, - "error": failure_message.replace("\n", " "), - } - ) - - changed = succeeded > 0 - if not tx.ops: - note = "Calendar unchanged: no sync operations were needed." - elif tx.status == "committed" and changed: - note = ( - "Calendar changed: " - f"{created} created, {updated} updated, {deleted} deleted." - ) - elif tx.status == "partial": - note = ( - "Calendar partially changed: " - f"{created} created, {updated} updated, {deleted} deleted, {failed} failed." - ) - else: - note = ( - "Calendar sync finished with status " - f"`{tx.status}` ({created} created, {updated} updated, " - f"{deleted} deleted, {failed} failed)." - ) - return CalendarSyncOutcome( - status=tx.status, - changed=changed, - created=created, - updated=updated, - deleted=deleted, - failed=failed, - note=note, - failed_details=failed_details, - ) - - def _build_reconciliation_summary( - self, - *, - session: Session, - context: str, - ) -> ReconciliationSummary | None: - """Return deterministic create/update/noop/delete counts for current submit.""" - if session.base_snapshot is None or session.tb_plan is None: - return None - try: - summary = summarize_reconciliation( - remote=session.base_snapshot, - desired=session.tb_plan, - event_id_map=session.event_id_map, - remote_event_ids_by_index=session.remote_event_ids_by_index, - ) - except Exception as exc: - self._session_debug( - session, - "reconciliation_summary_error", - context=context, - error_type=type(exc).__name__, - error=str(exc)[:1000], - ) - return None - - self._session_debug( - session, - "reconciliation_summary", - context=context, - remote_fetched=summary.remote_fetched, - matched=summary.matched, - create=summary.create, - update=summary.update, - noop=summary.noop, - delete=summary.delete, - ) - return summary - - @staticmethod - def _format_reconciliation_summary( - summary: ReconciliationSummary, - ) -> str: - """Render deterministic reconciliation counts for user-visible submit output.""" - return ( - "Reconciliation: " - f"remote fetched {summary.remote_fetched}, " - f"matched {summary.matched}, " - f"create {summary.create}, " - f"update {summary.update}, " - f"noop {summary.noop}, " - f"delete {summary.delete}." - ) - - async def _submit_current_plan(self, session: Session) -> CalendarSyncOutcome: - """Sync the current TBPlan and return a structured calendar-change result.""" - if session.tb_plan is None: - self._session_debug( - session, - "calendar_sync_skipped", - reason="missing_tb_plan", - ) - return CalendarSyncOutcome( - status="skipped", - changed=False, - note="Calendar sync skipped: plan is not ready yet.", - ) - baseline_guard = await self._ensure_submit_baseline_ready( - session=session, - context="stage4_submit_current_plan", - ) - if not baseline_guard.ready: - self._session_debug( - session, - "calendar_sync_skipped", - reason=baseline_guard.reason, - ) - if baseline_guard.reason == "missing_base_snapshot": - note = ( - "Calendar sync skipped: baseline snapshot unavailable. " - "Click Redo to retry sync." - ) - else: - note = ( - "Calendar sync skipped: couldn't refresh the latest calendar " - "baseline safely. Retry with Redo." - ) - return CalendarSyncOutcome( - status="skipped", - changed=False, - note=note, - ) - - sync_started_at = perf_counter() - self._session_debug( - session, - "calendar_sync_start", - remote_events=len(session.base_snapshot.events), - event_id_map_size=len(session.event_id_map), - ) - previous_map = dict(session.event_id_map) - try: - tx = await self._calendar_submitter.submit_plan( - desired=session.tb_plan, - remote=session.base_snapshot, - event_id_map=session.event_id_map, - remote_event_ids_by_index=session.remote_event_ids_by_index, - ) - except Exception as exc: - logger.exception("Calendar sync failed during refine stage.") - self._session_debug( - session, - "calendar_sync_error", - error_type=type(exc).__name__, - error=str(exc)[:2000], - elapsed_s=round(perf_counter() - sync_started_at, 3), - ) - return CalendarSyncOutcome( - status="failed", - changed=False, - note="Calendar sync failed; keep refining and try again.", - ) - session.committed = True - session.last_sync_transaction = tx - session.last_sync_event_id_map = previous_map - session.event_id_map = self._update_event_id_map_after_submit( - session=session, - transaction=tx, - ) - await self._refresh_remote_baseline_after_sync(session) - outcome = self._summarize_sync_transaction(tx) - self._session_debug( - session, - "calendar_sync_result", - status=outcome.status, - changed=outcome.changed, - created=outcome.created, - updated=outcome.updated, - deleted=outcome.deleted, - failed=outcome.failed, - failed_ops=outcome.failed_details[:3], - ops=len(tx.ops), - elapsed_s=round(perf_counter() - sync_started_at, 3), - ) - return outcome - - async def _execute_refine_patch_and_sync( - self, - *, - session: Session, - patch_message: str, - ) -> CalendarSyncOutcome: - """Run Stage 4 patching and keep changes local until explicit Stage 5 submit.""" - if session.tb_plan is None: - raise ValueError("Refine patch requested without TBPlan state.") - previous_plan = session.tb_plan.model_copy(deep=True) - previous_timebox = ( - session.timebox.model_copy(deep=True) if session.timebox is not None else None - ) - # Cleared before the pass, not after. It is written only inside the - # selector, so a Stage 4 render that happens before or without a - # patcher call reported the *previous* pass's number as this one's -- - # which is how a stale 0 came to look like a filter dropping every - # constraint, and sent two of us after a narrowing step that does not - # exist. - session.last_refine_selected_constraints_count = 0 - constraints = await self._collect_constraints(session) - patch_constraints = self._select_constraints_for_refine_patcher( - session=session, - constraints=constraints, - ) - validated_timebox: Timebox | None = None - - def _materialize_timebox(plan: TBPlan) -> Timebox: - nonlocal validated_timebox - validated_timebox = tb_plan_to_timebox(plan) - return validated_timebox - - patched_plan, _patch = await self._timebox_patcher.apply_patch( - stage=TimeboxingStage.REFINE.value, - current=session.tb_plan, - user_message=patch_message, - constraints=patch_constraints, - actions=[], - plan_validator=_materialize_timebox, - ) - session.tb_plan = patched_plan - if validated_timebox is None: - raise ValueError("Patch completed without producing a validated Timebox.") - session.timebox = validated_timebox - plan_changed = patched_plan.model_dump(mode="json") != previous_plan.model_dump( - mode="json" - ) - if plan_changed: - session.consecutive_refine_no_change = 0 - session.last_refine_undo_tb_plan = previous_plan - session.last_refine_undo_timebox = previous_timebox - note = "Plan updated locally. Review Stage 5 and click Submit to sync to calendar." - else: - session.consecutive_refine_no_change += 1 - session.last_refine_undo_tb_plan = None - session.last_refine_undo_timebox = None - self._session_debug( - session, - "refine_no_change", - consecutive=session.consecutive_refine_no_change, - limit=_REFINE_NO_CHANGE_LIMIT, - constraints_in=len(constraints or []), - constraints_selected=len(patch_constraints or []), - ) - if session.consecutive_refine_no_change >= _REFINE_NO_CHANGE_LIMIT: - # Advance-or-fail. A stage that cannot change anything has - # nothing to add by running again, and each pass re-renders the - # constraint list -- so the loop is not merely wasteful, it is - # what grows the message until Slack refuses the edit and the - # only error channel goes down with it. Raising converts a hang - # into something the user can see. - session.consecutive_refine_no_change = 0 - raise RefineMadeNoProgress( - f"Refine ran {_REFINE_NO_CHANGE_LIMIT} times without changing " - f"the plan. It received {len(constraints or [])} constraints " - f"and selected {len(patch_constraints or [])} for patching. " - f"Stopping rather than re-rendering the same day again." - ) - note = "No local schedule changes were needed. Review Stage 5 and click Submit to sync." - return CalendarSyncOutcome( - status="staged", - changed=plan_changed, - note=note, - ) - - async def _undo_last_refine_update( - self, *, session: Session - ) -> TextMessage | None: - """Restore the previous local draft after a Stage 4 patch.""" - if ( - session.stage != TimeboxingStage.REFINE - or session.last_refine_undo_tb_plan is None - ): - return None - - session.tb_plan = session.last_refine_undo_tb_plan.model_copy(deep=True) - restored_timebox = ( - session.last_refine_undo_timebox.model_copy(deep=True) - if session.last_refine_undo_timebox is not None - else tb_plan_to_timebox(session.tb_plan) - ) - session.timebox = restored_timebox - session.last_refine_undo_tb_plan = None - session.last_refine_undo_timebox = None - gate = await self._run_timebox_summary( - stage=TimeboxingStage.REFINE, - timebox=restored_timebox, - session=session, - ) - gate.summary.append("Last local update was undone.") - session.stage_ready = True - session.stage_missing = [] - session.stage_question = gate.question - self._session_debug(session, "refine_update_undone") - text = self._format_stage_message( - gate, - background_notes=self._collect_background_notes(session), - constraints=session.active_constraints, - immovables=session.frame_facts.get("immovables"), - ) - return TextMessage(content=text, source=self._agent_source()) - - @staticmethod - def _select_refine_tool_intents( - intents: list[tuple[int, str, str]], - ) -> tuple[str | None, str | None]: - """Select highest-priority patch intent and highest-priority memory intent. - - Intents are ``(priority, kind, text)`` tuples where lower priority numbers - are preferred. Returns ``(patch_text, memory_text)`` β€” either may be ``None`` - when no intent of that kind is present. - """ - sorted_intents = sorted(intents, key=lambda x: x[0]) - patch = next( - (text for _, kind, text in sorted_intents if kind == "patch"), None - ) - memory = next( - (text for _, kind, text in sorted_intents if kind == "memory"), None - ) - return patch, memory - - @staticmethod - def _build_refine_noop_execution(*, note: str) -> RefineToolExecutionOutcome: - """Build a no-op refine outcome when no user edits were requested.""" - return RefineToolExecutionOutcome( - patch_selected=False, - memory_queued=False, - fallback_patch_used=False, - calendar=CalendarSyncOutcome( - status="skipped", - changed=False, - note=note, - ), - memory_operations=[], - ) - - # TODO(deprecate): _ensure_constraint_mcp_tools is never called at runtime. - # The live extraction write path is _upsert_constraints_to_durable_store. - # Remove this method together with get_constraint_mcp_tools and NotionConstraintExtractor. - async def _ensure_constraint_mcp_tools(self) -> None: - """Lazily initialise constraint MCP tools, extractor, and extraction tool. - - Idempotent β€” safe to call multiple times; initialisation only runs once. - """ - if self._constraint_mcp_tools is not None: - return - tools = await get_constraint_mcp_tools() - self._constraint_mcp_tools = tools - self._notion_extractor = NotionConstraintExtractor( - model_client=self._model_client, - tools=tools, - ) - extractor = self._notion_extractor - - async def _extract_and_queue( - *, - planned_date: str, - timezone: str, - stage_id: str, - user_utterance: str, - triggering_suggestion: str, - impacted_event_types: list[str], - suggested_tags: list[str], - decision_scope: str, - ) -> dict: - """Queue a durable constraint extraction without blocking the stage gate.""" - asyncio.create_task( - extractor.extract_and_upsert_constraint( - planned_date=planned_date, - timezone=timezone, - stage_id=stage_id, - user_utterance=user_utterance, - triggering_suggestion=triggering_suggestion, - impacted_event_types=list(impacted_event_types), - suggested_tags=list(suggested_tags), - decision_scope=decision_scope, - ) - ) - return {"queued": True} - - self._constraint_extractor_tool = FunctionTool( - _extract_and_queue, - name="extract_and_upsert_constraint", - description="Queue a durable constraint extraction from user utterance.", - strict=True, - ) - - @staticmethod - def _materialize_timebox_from_tb_plan(session: Session) -> None: - """Ensure ``session.timebox`` is available from the current ``session.tb_plan``.""" - if session.timebox is None and session.tb_plan is not None: - session.timebox = tb_plan_to_timebox(session.tb_plan) - - async def _run_refine_tool_orchestration( - self, - *, - session: Session, - patch_message: str, - user_message: str, - ) -> RefineToolExecutionOutcome: - """Run prompt-guided patch tooling while always queueing memory in background.""" - requested_patch: list[str] = [] - memory_operations: list[str] = [] - memory_request_text = (user_message or "").strip() or ( - patch_message or "" - ).strip() - - async def timebox_patch_plan(user_instruction: str) -> dict[str, Any]: - instruction = (user_instruction or "").strip() - if instruction: - requested_patch.append(instruction) - return {"queued": True, "priority": "critical"} - - async def memory_list_constraints( - text_query: str | None, - statuses: list[str] | None, - scopes: list[str] | None, - necessities: list[str] | None, - tags: list[str] | None, - limit: int, - ) -> dict[str, Any]: - return await self._run_memory_tool_action_guarded( - action="list", - session=session, - memory_operations=memory_operations, - memory_request_text=memory_request_text, - text_query=text_query, - statuses=statuses, - scopes=scopes, - necessities=necessities, - tags=tags, - limit=limit, - ) - - async def memory_get_constraint(uid: str) -> dict[str, Any]: - return await self._run_memory_tool_action_guarded( - action="get", - session=session, - memory_operations=memory_operations, - memory_request_text=memory_request_text, - uid=uid, - ) - - async def memory_update_constraint( - uid: str, - patch_json: str, - note: str | None, - ) -> dict[str, Any]: - parsed_patch, parse_error = self._parse_memory_patch_json(patch_json) - if parse_error: - return self._record_memory_tool_result( - session=session, - result=MemoryToolResult( - action="update", - ok=False, - uid=str(uid or "").strip() or None, - error="invalid_patch_json", - message=parse_error, - ), - ) - return await self._run_memory_tool_action_guarded( - action="update", - session=session, - memory_operations=memory_operations, - memory_request_text=memory_request_text, - uid=uid, - patch=parsed_patch, - note=note, - ) - - async def memory_archive_constraint( - uid: str, - reason: str | None, - ) -> dict[str, Any]: - return await self._run_memory_tool_action_guarded( - action="archive", - session=session, - memory_operations=memory_operations, - memory_request_text=memory_request_text, - uid=uid, - reason=reason, - ) - - async def memory_supersede_constraint( - uid: str, - patch_json: str, - reason: str | None, - ) -> dict[str, Any]: - parsed_patch, parse_error = self._parse_memory_patch_json(patch_json) - if parse_error: - return self._record_memory_tool_result( - session=session, - result=MemoryToolResult( - action="supersede", - ok=False, - uid=str(uid or "").strip() or None, - error="invalid_patch_json", - message=parse_error, - ), - ) - return await self._run_memory_tool_action_guarded( - action="supersede", - session=session, - memory_operations=memory_operations, - memory_request_text=memory_request_text, - uid=uid, - patch=parsed_patch, - reason=reason, - ) - - tool_agent = AssistantAgent( - name="StageRefineExecutionPlanner", - model_client=self._model_client, - tools=[ - FunctionTool( - timebox_patch_plan, - name="timebox_patch_plan", - description=( - "Primary tool for Stage 4/5 schedule edits. " - "Use this to apply the requested patch and stage it locally for review." - ), - strict=True, - ), - FunctionTool( - memory_list_constraints, - name="memory_list_constraints", - description=( - "Review durable memory constraints/preferences. Use when the user asks " - "what is remembered or wants to inspect active constraints." - ), - strict=True, - ), - FunctionTool( - memory_get_constraint, - name="memory_get_constraint", - description="Get one durable memory constraint by uid.", - strict=True, - ), - FunctionTool( - memory_update_constraint, - name="memory_update_constraint", - description=( - "Edit one durable memory constraint by uid. " - "Provide `patch_json` as a JSON object string " - '(for example "{"status":"locked"}" or "{"json_patch_ops":[...]}"). ' - "Use for explicit user requests to revise remembered preferences." - ), - strict=True, - ), - FunctionTool( - memory_archive_constraint, - name="memory_archive_constraint", - description=( - "Archive one durable memory constraint by uid. Use when the user says " - "a remembered rule is no longer valid." - ), - strict=True, - ), - FunctionTool( - memory_supersede_constraint, - name="memory_supersede_constraint", - description=( - "Supersede an existing durable memory constraint by uid with a new record. " - "Provide `patch_json` as a JSON object string." - ), - strict=True, - ), - ], - system_message=( - "You are selecting tools for Stage 4/5 timeboxing execution.\n" - "Primary objective: apply user-requested schedule changes now.\n" - "Rules:\n" - "1) If the user asks for any plan/calendar change, call `timebox_patch_plan` exactly once.\n" - "2) If the user asks to review or edit remembered constraints/preferences, " - "call the appropriate memory_* tool.\n" - "3) If both schedule patching and memory edits are requested, call patch tool first, " - "then memory tools.\n" - "4) Memory extraction/upsert runs automatically in the background and is NOT a tool choice.\n" - "5) Return a brief confirmation after tool calls." - ), - reflect_on_tool_use=False, - max_tool_iterations=3, - memory=[ - self._build_refine_memory_component(session=session), - ], - ) - await with_timeout( - "timeboxing:refine-tool-orchestration", - tool_agent.on_messages( - [ - TextMessage( - content=( - f"stage={session.stage.value}\n" - f"user_message={user_message.strip()}\n" - f"patch_message={patch_message}" - ), - source="user", - ) - ], - CancellationToken(), - ), - timeout_s=TIMEBOXING_TIMEOUTS.stage_decision_s, - ) - - patch_instruction = self._select_patch_instruction(requested_patch) - - fallback_patch_used = False - if not patch_instruction and not self._looks_like_memory_management_request( - memory_request_text - ): - patch_instruction = patch_message - fallback_patch_used = True - - memory_instruction = (user_message or "").strip() or ( - patch_message or "" - ).strip() - memory_queued = False - if memory_instruction: - task = self._queue_constraint_extraction( - session=session, - text=memory_instruction, - reason="refine_background_memory", - is_initial=False, - ) - memory_queued = task is not None - if memory_queued: - self._append_background_update_once( - session, - "Updating preference memory in the background.", - ) - - if patch_instruction: - calendar = await self._execute_refine_patch_and_sync( - session=session, - patch_message=patch_instruction, - ) - else: - calendar = CalendarSyncOutcome( - status="skipped", - changed=False, - note="No calendar patch requested in this turn.", - ) - self._queue_reflection_memory_write( - session=session, - user_message=user_message or patch_message, - calendar=calendar, - memory_operations=list(memory_operations), - ) - return RefineToolExecutionOutcome( - patch_selected=bool(patch_instruction and not fallback_patch_used), - memory_queued=memory_queued, - fallback_patch_used=fallback_patch_used, - calendar=calendar, - memory_operations=memory_operations, - ) - - async def _run_memory_tool_action_guarded( - self, - *, - action: Literal["list", "get", "update", "archive", "supersede"], - session: Session, - memory_operations: list[str], - memory_request_text: str, - uid: str | None = None, - patch: dict[str, Any] | None = None, - reason: str | None = None, - note: str | None = None, - text_query: str | None = None, - statuses: list[str] | None = None, - scopes: list[str] | None = None, - necessities: list[str] | None = None, - tags: list[str] | None = None, - limit: int = 20, - ) -> dict[str, Any]: - """Execute one memory action and preserve stage flow on backend failures.""" - try: - return await self._run_memory_tool_action( - action=action, - session=session, - memory_operations=memory_operations, - memory_request_text=memory_request_text, - uid=uid, - patch=patch, - reason=reason, - note=note, - text_query=text_query, - statuses=statuses, - scopes=scopes, - necessities=necessities, - tags=tags, - limit=limit, - ) - except Exception as exc: # pragma: no cover - error = f"{type(exc).__name__}: {exc}" - cleaned_uid = str(uid or "").strip() or None - self._constraint_memory_unavailable_reason = error - self._durable_constraint_store = None - logger.warning( - "Memory tool action failed (action=%s uid=%s): %s", - action, - cleaned_uid or "", - error, - exc_info=True, - ) - self._append_background_update_once( - session, - "Memory backend is currently unavailable; continuing without durable memory updates.", - ) - self._session_debug( - session, - "memory_tool_action_error", - action=action, - uid=cleaned_uid, - error=error[:500], - ) - return self._record_memory_tool_result( - session=session, - result=MemoryToolResult( - action=action, - ok=False, - uid=cleaned_uid, - error=error, - message=( - "Memory backend is unavailable right now. " - "Scheduling can continue without durable memory updates." - ), - ), - ) - - async def _run_memory_tool_action( - self, - *, - action: Literal["list", "get", "update", "archive", "supersede"], - session: Session, - memory_operations: list[str], - memory_request_text: str, - uid: str | None = None, - patch: dict[str, Any] | None = None, - reason: str | None = None, - note: str | None = None, - text_query: str | None = None, - statuses: list[str] | None = None, - scopes: list[str] | None = None, - necessities: list[str] | None = None, - tags: list[str] | None = None, - limit: int = 20, - ) -> dict[str, Any]: - """Execute one durable-memory tool action with shared validation and logging.""" - store = self._ensure_durable_constraint_store() - if store is None: - return self._record_memory_tool_result( - session=session, - result=MemoryToolResult( - action=action, - ok=False, - error="durable memory store unavailable", - message="Memory backend is unavailable right now.", - ), - ) - - match action: - case "list": - query_limit = max(1, min(int(limit or 20), 100)) - filters: dict[str, Any] = { - "as_of": ( - session.planned_date or datetime.utcnow().date().isoformat() - ), - "require_active": False, - "stage": session.stage.value if session.stage else None, - } - if (text_query or "").strip(): - filters["text_query"] = str(text_query).strip() - if statuses: - filters["statuses_any"] = statuses - if scopes: - filters["scopes_any"] = scopes - if necessities: - filters["necessities_any"] = necessities - rows = await store.query_constraints( - filters=filters, - type_ids=None, - tags=tags or None, - sort=[["Status", "descending"], ["Name", "ascending"]], - limit=query_limit, - ) - active_constraints = await self._collect_constraints(session) - active_uids = { - uid - for uid in ( - self._constraint_uid(constraint) - for constraint in (active_constraints or []) - ) - if uid and uid not in session.suppressed_durable_uids - } - items: list[MemoryConstraintItem] = [] - for row in rows: - if not isinstance(row, dict): - continue - item = MemoryConstraintItem.from_payload(row) - if item is None: - continue - item.used_this_session = item.uid in active_uids - items.append(item) - memory_operations.append(f"list:{len(items)}") - message = ( - "Memory review: no matching constraints found." - if not items - else ( - "Memory review: found " - f"{len(items)} constraint(s) " - f"({', '.join(item.name or item.uid for item in items[:3])})." - ) - ) - self._append_background_update_once(session, message) - return self._record_memory_tool_result( - session=session, - result=MemoryToolResult( - action="list", - ok=True, - message=message, - count=len(items), - constraints=items, - ), - ) - case "get" | "update" | "archive" | "supersede": - cleaned_uid = str(uid or "").strip() - if not cleaned_uid: - return self._record_memory_tool_result( - session=session, - result=MemoryToolResult( - action=action, - ok=False, - error="uid is required", - message="Memory action needs a constraint uid.", - ), - ) - case _: - return self._record_memory_tool_result( - session=session, - result=MemoryToolResult( - action="list", - ok=False, - error=f"unsupported action: {action}", - ), - ) - - match action: - case "get": - item = await store.get_constraint(uid=cleaned_uid) - if not item: - return self._record_memory_tool_result( - session=session, - result=MemoryToolResult( - action="get", - ok=False, - uid=cleaned_uid, - error="constraint not found", - message=f"Constraint `{cleaned_uid}` was not found in memory.", - ), - ) - memory_operations.append(f"get:{cleaned_uid}") - parsed = MemoryConstraintItem.from_payload(item) - result = MemoryToolResult( - action="get", - ok=True, - uid=cleaned_uid, - message=f"Loaded remembered constraint `{cleaned_uid}`.", - constraints=[parsed] if parsed else [], - ) - payload = self._record_memory_tool_result( - session=session, result=result - ) - if parsed: - payload["constraint"] = item - return payload - case "update": - result = await store.update_constraint( - uid=cleaned_uid, - patch=patch if isinstance(patch, dict) else {}, - event={ - "action": "update", - "stage": session.stage.value if session.stage else None, - "note": (note or "").strip() or None, - "user_utterance": memory_request_text, - }, - ) - parsed_item = None - if result.get("updated"): - memory_operations.append(f"update:{cleaned_uid}") - self._append_background_update_once( - session, - f"Updated durable memory for constraint `{cleaned_uid}`.", - ) - parsed_item = MemoryConstraintItem.from_payload( - await store.get_constraint(uid=cleaned_uid) or {} - ) - return self._record_memory_tool_result( - session=session, - result=MemoryToolResult( - action="update", - ok=bool(result.get("updated")), - uid=cleaned_uid, - error=( - None - if result.get("updated") - else str(result.get("reason") or "") - ), - message=( - f"Updated remembered constraint `{cleaned_uid}`." - if result.get("updated") - else f"Unable to update remembered constraint `{cleaned_uid}`." - ), - constraints=[parsed_item] if parsed_item else [], - ), - ) - case "archive": - result = await store.archive_constraint(uid=cleaned_uid, reason=reason) - parsed_item = None - if result.get("updated"): - memory_operations.append(f"archive:{cleaned_uid}") - self._append_background_update_once( - session, - f"Archived durable memory for constraint `{cleaned_uid}`.", - ) - parsed_item = MemoryConstraintItem.from_payload( - await store.get_constraint(uid=cleaned_uid) or {} - ) - return self._record_memory_tool_result( - session=session, - result=MemoryToolResult( - action="archive", - ok=bool(result.get("updated")), - uid=cleaned_uid, - error=( - None - if result.get("updated") - else str(result.get("reason") or "") - ), - message=( - f"Archived remembered constraint `{cleaned_uid}`." - if result.get("updated") - else f"Unable to archive remembered constraint `{cleaned_uid}`." - ), - constraints=[parsed_item] if parsed_item else [], - ), - ) - case "supersede": - current = await store.get_constraint(uid=cleaned_uid) - if not current: - return self._record_memory_tool_result( - session=session, - result=MemoryToolResult( - action="supersede", - ok=False, - uid=cleaned_uid, - error="constraint not found", - message=f"Constraint `{cleaned_uid}` was not found in memory.", - ), - ) - patch_payload = patch if isinstance(patch, dict) else {} - if isinstance(patch_payload.get("constraint_record"), dict): - new_record = { - "constraint_record": dict(patch_payload["constraint_record"]) - } - else: - merged = dict(current.get("constraint_record") or {}) - merged.update( - {k: v for k, v in patch_payload.items() if v is not None} - ) - new_record = {"constraint_record": merged} - result = await store.supersede_constraint( - uid=cleaned_uid, - new_record=new_record, - event={ - "action": "supersede", - "reason": (reason or "").strip() or None, - "stage": session.stage.value if session.stage else None, - "user_utterance": memory_request_text, - }, - ) - if result.get("updated"): - memory_operations.append(f"supersede:{cleaned_uid}") - self._append_background_update_once( - session, - f"Superseded durable memory for constraint `{cleaned_uid}`.", - ) - new_uid = str(result.get("new_uid") or result.get("uid") or "").strip() - parsed_item = None - if new_uid: - parsed_item = MemoryConstraintItem.from_payload( - await store.get_constraint(uid=new_uid) or {} - ) - return self._record_memory_tool_result( - session=session, - result=MemoryToolResult( - action="supersede", - ok=bool(result.get("updated")), - uid=new_uid or cleaned_uid, - error=( - None - if result.get("updated") - else str(result.get("reason") or "") - ), - message=( - f"Superseded remembered constraint `{cleaned_uid}`." - if result.get("updated") - else f"Unable to supersede remembered constraint `{cleaned_uid}`." - ), - constraints=[parsed_item] if parsed_item else [], - ), - ) - case _: - return self._record_memory_tool_result( - session=session, - result=MemoryToolResult( - action="list", - ok=False, - error=f"unsupported action: {action}", - ), - ) - - def _queue_reflection_memory_write( - self, - *, - session: Session, - user_message: str, - calendar: CalendarSyncOutcome, - memory_operations: list[str], - ) -> None: - """Persist a lightweight per-turn reflection entry in durable memory.""" - text = (user_message or "").strip() - if not text: - return - store = self._ensure_durable_constraint_store() - if store is None: - return - payload = { - "user_id": session.user_id, - "stage": session.stage.value if session.stage else None, - "planned_date": session.planned_date, - "calendar_status": calendar.status, - "calendar_changed": calendar.changed, - "memory_operations": list(memory_operations or []), - "summary": ( - f"Stage {session.stage.value if session.stage else 'unknown'} " - f"calendar={calendar.status} changed={calendar.changed}" - ), - "user_utterance": text, - } - - async def _background() -> None: - try: - await store.add_reflection(payload=payload) - except Exception as exc: - logger.warning( - "Reflection memory write failed: %s", - exc, - exc_info=True, - ) - self._session_debug( - session, - "reflection_memory_error", - error_type=type(exc).__name__, - error=str(exc)[:500], - ) - - asyncio.create_task(_background()) - - def _build_refine_memory_component( - self, *, session: Session - ) -> ConstraintPlanningMemory: - """Build a per-turn AutoGen memory component for stage-aware constraint injection.""" - component = ConstraintPlanningMemory( - store_provider=self._ensure_durable_constraint_store, - max_items=12, - ) - component.set_planning_state( - { - "stage": session.stage.value if session.stage else None, - "planned_date": session.planned_date, - "event_types": [], - } - ) - return component - - @staticmethod - def _select_patch_instruction(requested_patch: list[str]) -> str: - """Return the first valid patch instruction from tool-selected operations.""" - for instruction in requested_patch: - cleaned = (instruction or "").strip() - if cleaned: - return cleaned - return "" - - @staticmethod - def _looks_like_schedule_request(text: str) -> bool: - """Heuristic to detect explicit schedule/calendar patch intent.""" - lowered = (text or "").strip().lower() - if not lowered: - return False - schedule_markers = ( - "move", - "reschedule", - "shift", - "patch", - "calendar", - "timebox", - "schedule", - "block", - "add buffer", - "remove buffer", - "today plan", - "tomorrow plan", - ) - if any(marker in lowered for marker in schedule_markers): - return True - return bool( - re.search( - r"\b([01]?\d|2[0-3]):[0-5]\d\b|\b(today|tomorrow|tonight|morning|afternoon|evening)\b", - lowered, - ) - ) - - @staticmethod - def _looks_like_memory_management_request(text: str) -> bool: - """Heuristic to detect explicit memory review/edit commands.""" - lowered = (text or "").strip().lower() - if not lowered: - return False - if TimeboxingFlowAgent._looks_like_schedule_request(lowered): - return False - memory_markers = ( - "memory", - "remember", - "constraint", - "preference", - "saved rule", - "what do you know", - "what do you remember", - "show my", - "list my", - "update my", - "edit my", - "delete", - "remove", - "archive", - "forget", - ) - has_memory = any(marker in lowered for marker in memory_markers) - if not has_memory: - return False - explicit_memory_only = ( - lowered.startswith("show my") - or lowered.startswith("list my") - or lowered.startswith("what do you remember") - or lowered.startswith("what do you know") - or lowered.startswith("forget ") - or lowered.startswith("archive ") - or lowered.startswith("delete ") - or lowered.startswith("edit my preference") - ) - return explicit_memory_only or has_memory - - async def _build_skeleton_context(self, session: Session) -> SkeletonContext: - """Assemble the injected context for the skeleton drafter.""" - await self._ensure_calendar_immovables( - session, timeout_s=TIMEBOXING_TIMEOUTS.calendar_prefetch_wait_s - ) - constraints = await self._collect_constraints(session) - planned = self._resolve_planning_date(session) - tz_name = session.tz_name or "UTC" - - immovables = parse_model_list(Immovable, session.frame_facts.get("immovables")) - work_window = parse_model_optional( - WorkWindow, session.frame_facts.get("work_window") - ) - sleep_target = parse_model_optional( - SleepTarget, session.frame_facts.get("sleep_target") - ) - block_plan = parse_model_optional( - BlockPlan, session.input_facts.get("block_plan") - ) - daily_one_thing = parse_model_optional( - DailyOneThing, session.input_facts.get("daily_one_thing") - ) - tasks = parse_model_list(TaskCandidate, session.input_facts.get("tasks")) - - return SkeletonContext( - date=planned, - timezone=tz_name, - work_window=work_window, - sleep_target=sleep_target, - immovables=immovables, - block_plan=block_plan, - daily_one_thing=daily_one_thing, - tasks=tasks, - constraints_snapshot=list(constraints or []), - ) - - def _build_fallback_skeleton_timebox(self, session: Session) -> Timebox: - """Build a minimal timebox when skeleton drafting fails or times out.""" - planning_date = self._resolve_planning_date(session) - tz_name = session.tz_name or "UTC" - immovables = self._normalize_calendar_events( - session.frame_facts.get("immovables") - ) - events = immovables or [self._build_focus_block_event(timezone=tz_name)] - try: - return Timebox(events=events, date=planning_date, timezone=tz_name) - except Exception: - logger.debug("Fallback timebox failed; returning focus block only.") - focus_block = self._build_focus_block_event(timezone=tz_name) - return Timebox(events=[focus_block], date=planning_date, timezone=tz_name) - - def _resolve_planning_date(self, session: Session) -> date: - """Resolve the planning date from session state or default to today.""" - if session.planned_date: - # TODO(refactor): Parse planned_date via a Pydantic schema. - try: - return date.fromisoformat(session.planned_date) - except ValueError: - logger.debug( - "Invalid planned_date=%s; defaulting to today.", - session.planned_date, - ) - return date.today() - - def _normalize_calendar_events(self, immovables: Any | None) -> list[CalendarEvent]: - """Normalize immovable payloads into CalendarEvent instances.""" - raw_calendar_events = parse_model_list(CalendarEvent, immovables) - normalized_events = parse_model_list( - _CalendarSnapshotEvent, - [ - { - "summary": getattr(event, "summary", None), - "event_type": getattr(event, "event_type", None), - "start_time": getattr(event, "start_time", None), - "end_time": getattr(event, "end_time", None), - "duration": getattr(event, "duration", None), - "start": getattr(event, "start", None), - "end": getattr(event, "end", None), - "calendarId": getattr(event, "calendarId", None), - "timeZone": getattr(event, "timeZone", None), - "description": getattr(event, "description", None), - "eventId": getattr(event, "eventId", None), - } - for event in raw_calendar_events - ], - ) - if normalized_events: - return self._sort_calendar_events( - [event.to_calendar_event() for event in normalized_events] - ) - immovable_rows = parse_model_list(Immovable, immovables) - if not immovable_rows: - return [] - events = [ - event.to_calendar_event() - for event in parse_model_list( - _CalendarSnapshotEvent, - [ - { - "summary": row.title, - "event_type": EventType.MEETING, - "start_time": row.start, - "end_time": row.end, - "calendarId": "primary", - "timeZone": settings.planning_timezone, - } - for row in immovable_rows - ], - ) - ] - return self._sort_calendar_events(events) - - def _sort_calendar_events(self, events: list[CalendarEvent]) -> list[CalendarEvent]: - """Sort events by their scheduled times for deterministic ordering.""" - return sorted(events, key=self._calendar_event_sort_key) - - def _calendar_event_sort_key(self, event: CalendarEvent) -> time: - """Build a stable sort key for calendar events.""" - if event.start_time: - return event.start_time - if event.end_time: - return event.end_time - return time.max - - def _build_focus_block_event(self, *, timezone: str) -> CalendarEvent: - """Return a default focus block event for fallback timeboxes.""" - return CalendarEvent( - summary="Focus Block", - event_type=EventType.DEEP_WORK, - start_time=time(9, 0), - duration=timedelta(minutes=TIMEBOXING_FALLBACK.focus_block_minutes), - calendarId="primary", - timeZone=timezone, - ) - - async def _run_assist_turn( - self, - *, - session: Session, - user_message: str, - note: str | None, - assist_target: str | None, - ) -> str | None: - """Handle adjacent assist requests without progressing the stage.""" - match (assist_target or "").strip(): - case "tasks_agent": - return await self._task_marshalling.assist_response( - session=session, - user_message=user_message, - note=note, - ) - case _: - return None - - async def _maybe_handle_memory_review_turn( - self, *, session: Session, user_message: str - ) -> TextMessage | None: - """Handle pure memory-review turns without progressing stage flow.""" - decision = await self._decide_memory_review_turn( - session=session, - user_message=user_message, - ) - if decision.action != "memory_review": - return None - memory_operations: list[str] = [] - payload = await self._run_memory_tool_action_guarded( - action="list", - session=session, - memory_operations=memory_operations, - memory_request_text=user_message, - text_query=(decision.text_query or "").strip() or None, - statuses=list(decision.statuses or []), - scopes=list(decision.scopes or []), - necessities=list(decision.necessities or []), - tags=list(decision.tags or []), - limit=int(decision.limit or 20), - ) - message = str( - payload.get("message") or "Memory review complete. See the attached rows." - ).strip() - self._session_debug( - session, - "memory_review_turn", - text=(user_message or "")[:500], - operations=list(memory_operations), - text_query=decision.text_query, - statuses=list(decision.statuses or []), - scopes=list(decision.scopes or []), - necessities=list(decision.necessities or []), - tags=list(decision.tags or []), - limit=decision.limit, - ) - return TextMessage(content=message, source=self.id.type) - - async def _decide_next_action( - self, session: Session, *, user_message: str - ) -> StageDecision: - """Decide how to advance the timeboxing stage based on user input. - - A fresh agent is built on each call so no prior-turn message history - is carried into the decision LLM. - """ - decision_agent = self._build_one_shot_agent( - "StageDecision", DECISION_PROMPT, StageDecision - ) - decision_ctx = toon_encode( - name="decision_ctx", - rows=[ - { - "current_stage": session.stage.value, - "stage_ready": session.stage_ready, - "stage_question": session.stage_question or "", - "user_message": user_message, - } - ], - fields=["current_stage", "stage_ready", "stage_question", "user_message"], - ) - missing = toon_encode( - name="stage_missing", - rows=[{"item": item} for item in (session.stage_missing or [])], - fields=["item"], - ) - payload = f"{decision_ctx}\n{missing}\n" - try: - response = await with_timeout( - "timeboxing:stage-decision", - decision_agent.on_messages( - [TextMessage(content=payload, source="user")], - CancellationToken(), - ), - timeout_s=TIMEBOXING_TIMEOUTS.stage_decision_s, - ) - except TimeoutError as exc: - error = f"Stage decision timeout: {type(exc).__name__}" - logger.warning(error) - self._session_debug( - session, - "stage_decision_timeout", - error=error, - ) - return StageDecision( - action="provide_info", - note="stage_decision_timeout", - ) - except Exception as exc: - error = f"Stage decision failed: {type(exc).__name__}: {exc}" - logger.error(error, exc_info=True) - self._session_debug( - session, - "stage_decision_error", - error=error[:2000], - ) - return StageDecision( - action="provide_info", - note="stage_decision_error", - ) - try: - return parse_chat_content(StageDecision, response) - except Exception as exc: - error = f"Stage decision parse failed: {type(exc).__name__}: {exc}" - logger.error(error, exc_info=True) - self._session_debug( - session, - "stage_decision_parse_error", - error=error[:2000], - ) - return StageDecision( - action="provide_info", - note="stage_decision_parse_error", - ) - - def _format_constraints_section( - self, constraints: list[Constraint], limit: int = 6 - ) -> list[str]: - """Format active constraints for display in stage responses.""" - lines: list[str] = [] - for constraint in constraints[:limit]: - name = (constraint.name or "Constraint").strip() - description = (constraint.description or "").strip() - if description: - lines.append(f"{name} β€” {description}") - else: - lines.append(name) - remaining = len(constraints) - len(lines) - if remaining > 0: - lines.append(f"...and {remaining} more") - return lines - - @staticmethod - def _constraint_needs_confirmation(constraint: ConstraintBase) -> bool: - """Return whether a constraint should be explicitly confirmed by the user.""" - hints = constraint.hints if isinstance(constraint.hints, dict) else {} - selector = constraint.selector if isinstance(constraint.selector, dict) else {} - if bool(hints.get("needs_confirmation") or selector.get("needs_confirmation")): - return True - confidence = getattr(constraint, "confidence", None) - if confidence is not None: - try: - return float(confidence) < 0.7 - except (TypeError, ValueError): - return False - if constraint.source == ConstraintSource.SYSTEM: - return True - if constraint.scope == ConstraintScope.DATESPAN and ( - constraint.start_date is None or constraint.end_date is None - ): - return True - return False - - def _format_assumptions_section( - self, constraints: list[Constraint], limit: int = 4 - ) -> list[str]: - """Format inferred/proposed constraints as deny-able assumptions.""" - assumptions = [ - constraint - for constraint in constraints - if constraint.status == ConstraintStatus.PROPOSED - and constraint.source == ConstraintSource.SYSTEM - ] - lines: list[str] = [] - for constraint in assumptions[:limit]: - name = (constraint.name or "Assumption").strip() - description = (constraint.description or "").strip() - suffix = ( - " (needs confirmation; deny/edit if wrong)" - if self._constraint_needs_confirmation(constraint) - else " (reply with deny to remove)" - ) - if description: - lines.append(f"{name}: {description}{suffix}") - else: - lines.append(f"{name}{suffix}") - remaining = len(assumptions) - len(lines) - if remaining > 0: - lines.append(f"...and {remaining} more assumption(s)") - return lines - - @staticmethod - def _sanitize_slack_markdown(text: str) -> str: - """Strip unsupported HTML-like disclosure tags from Slack markdown text.""" - cleaned = str(text or "") - cleaned = re.sub(r"]*>", "", cleaned, flags=re.IGNORECASE) - cleaned = re.sub(r"]*>", "", cleaned, flags=re.IGNORECASE) - cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) - return cleaned.strip() - - @staticmethod - def _render_session_message(message: SessionMessage) -> str: - """Render a structured session message into markdown text.""" - parts: list[str] = [] - for section in message.sections: - match section: - case NextStepsSection(): - parts.append(f"### {section.heading}") - lines = [ - TimeboxingFlowAgent._sanitize_slack_markdown(line) - for line in section.content - ] - lines = [line for line in lines if line] - if not lines: - continue - first, *rest = lines - parts.append(first) - if rest: - parts.append("\n".join([f"- {line}" for line in rest])) - case ConstraintsSection(): - parts.append(f"### {section.heading}") - top = [ - TimeboxingFlowAgent._sanitize_slack_markdown(line) - for line in section.content - ] - top = [line for line in top if line] - parts.append( - "\n".join([f"- {line}" for line in top]) if top else "- (none)" - ) - folded = [ - TimeboxingFlowAgent._sanitize_slack_markdown(line) - for line in section.folded_content - ] - folded = [line for line in folded if line] - if folded: - folded = TimeboxingFlowAgent._compact_constraint_lines_for_slack( - folded - ) - parts.extend( - [ - "### All active constraints", - "\n".join([f"- {line}" for line in folded]), - ] - ) - case FreeformSection(): - content = TimeboxingFlowAgent._sanitize_slack_markdown( - section.content - ) - parts.extend([f"### {section.heading}", content or "-"]) - return "\n".join(parts) - - @staticmethod - def _compact_constraint_lines_for_slack( - lines: list[str], *, max_lines: int = 25, max_chars: int = 2800 - ) -> list[str]: - """Keep folded constraint text under Slack-safe size bounds.""" - if not lines: - return [] - kept: list[str] = [] - used_chars = 0 - for line in lines[:max_lines]: - addition = len(line) + 2 - if kept and (used_chars + addition) > max_chars: - break - kept.append(line) - used_chars += addition - dropped = max(0, len(lines) - len(kept)) - if dropped > 0: - kept.append(f"...and {dropped} more (open full list to review)") - return kept - - @staticmethod - def _ordered_session_sections(message: SessionMessage) -> SessionMessage: - """Normalize section ordering so the final section is always next steps.""" - - def _section_priority(section: MessageSection) -> int: - kind = str(getattr(section, "kind", "freeform")) - if kind == "constraints": - return 0 - if kind == "next_steps": - return 2 - heading = str(getattr(section, "heading", "")).strip().lower() - if heading in {"what i need from you", "next steps"}: - return 2 - return 1 - - ordered = sorted( - enumerate(message.sections), - key=lambda pair: (_section_priority(pair[1]), pair[0]), - ) - return SessionMessage(sections=[section for _, section in ordered]) - - @staticmethod - def _stage_label(stage: TimeboxingStage) -> str: - """Return canonical stage label text.""" - stage_order = { - TimeboxingStage.COLLECT_CONSTRAINTS: "Stage 1/5 (CollectConstraints)", - TimeboxingStage.CAPTURE_INPUTS: "Stage 2/5 (CaptureInputs)", - TimeboxingStage.SKELETON: "Stage 3/5 (Skeleton)", - TimeboxingStage.REFINE: "Stage 4/5 (Refine)", - TimeboxingStage.REVIEW_COMMIT: "Stage 5/5 (ReviewCommit)", - } - return stage_order.get(stage, f"Stage ({stage.value})") - - @staticmethod - def _stage_status_line(gate: StageGateOutput) -> str: - """Return deterministic readiness status text for stage output.""" - return ( - "Status: ready to proceed." - if gate.ready - else "Status: waiting on required input." - ) - - @staticmethod - def _stage_button_affordance_line(gate: StageGateOutput) -> str: - """Return explicit button guidance for bottom-up Slack scanning.""" - if gate.stage_id == TimeboxingStage.REVIEW_COMMIT and gate.ready: - return "Use buttons below: Submit to Calendar or Keep Editing." - if gate.ready: - return "Use buttons below: Proceed, or Redo/Back/Cancel." - return "Use buttons below: after replying, click Redo (Back/Cancel also available)." - - def _apply_stage_response_contract( - self, - *, - gate: StageGateOutput, - message: SessionMessage, - ) -> SessionMessage: - """Enforce deterministic stage/status/action template on message sections.""" - sections = list(message.sections) - stage_line = self._stage_label(gate.stage_id) - status_line = self._stage_status_line(gate) - action_line = self._stage_button_affordance_line(gate) - - current_idx: int | None = None - for idx, section in enumerate(sections): - if isinstance(section, FreeformSection) and ( - section.heading or "" - ).strip().lower() == "current step": - current_idx = idx - break - if current_idx is None: - sections.insert( - 0, - FreeformSection( - heading="Current step", - content=f"{stage_line}\n{status_line}", - ), - ) - else: - current = sections[current_idx] - assert isinstance(current, FreeformSection) - current_text = (current.content or "").strip() - lines = [line for line in current_text.splitlines() if line.strip()] - if stage_line not in lines: - lines.insert(0, stage_line) - if status_line not in lines: - lines.append(status_line) - sections[current_idx] = FreeformSection( - heading=current.heading, - content="\n".join(lines), - ) - - next_idx: int | None = None - for idx, section in enumerate(sections): - if isinstance(section, NextStepsSection): - next_idx = idx - break - if isinstance(section, FreeformSection) and ( - section.heading or "" - ).strip().lower() in {"what i need from you", "next steps"}: - next_idx = idx - break - - if next_idx is None: - fallback_question = ( - (gate.question or "").strip() or "Share what to adjust next." - ) - sections.append( - NextStepsSection( - heading="What I need from you", - content=[fallback_question, action_line], - ) - ) - else: - current = sections[next_idx] - if isinstance(current, NextStepsSection): - content = [line for line in current.content if (line or "").strip()] - if action_line not in content: - content.append(action_line) - sections[next_idx] = NextStepsSection( - heading=current.heading, - content=content, - ) - else: - assert isinstance(current, FreeformSection) - content = (current.content or "").strip() - if action_line not in content: - content = f"{content}\n- {action_line}" if content else action_line - sections[next_idx] = FreeformSection( - heading=current.heading, - content=content, - ) - - return self._ordered_session_sections(SessionMessage(sections=sections)) - - def _build_collect_constraint_template_section( - self, *, gate: StageGateOutput, constraints: list[Constraint] | None - ) -> list[str] | None: - """Build the Stage 1 template coverage section from typed gate facts.""" - _ = (gate, constraints) - return None - - def _append_background_update_once(self, session: Session, note: str) -> None: - """Append a background note if it is not already queued.""" - if note in session.background_updates: - return - session.background_updates.append(note) - - def _sanitize_stage_summary_lines( - self, - *, - gate: StageGateOutput, - immovables: list[dict[str, str]] | None, - ) -> list[str]: - """Deduplicate stage-summary lines without post-hoc phrase filtering.""" - max_lines = 4 - lines: list[str] = [] - seen: set[str] = set() - for raw in gate.summary or []: - line = (raw or "").strip() - if not line: - continue - if line in seen: - continue - seen.add(line) - lines.append(line) - if len(lines) >= max_lines: - break - return lines - - def _format_immovables_section( - self, immovables: list[dict[str, str]], limit: int = 6 - ) -> list[str]: - """Format calendar immovables for display in stage responses.""" - lines: list[str] = [] - for item in immovables[:limit]: - title = (item.get("title") or "Busy").strip() - start = (item.get("start") or "").strip() - end = (item.get("end") or "").strip() - if start and end: - lines.append(f"{start}-{end} {title}") - else: - lines.append(title) - remaining = len(immovables) - len(lines) - if remaining > 0: - lines.append(f"...and {remaining} more") - return lines - - @staticmethod - def _format_schedule_lines(timebox: Timebox | None) -> list[str]: - """Render the plan's blocks as clock-time lines. - - Stage 4 tells the user to "review the refined schedule above". Whether - a schedule was actually rendered was left to the stage model, and in a - real session (2026-03-08) it never was: five passes of prose overview - while the patcher successfully grew the plan from 6 events to 13. The - user said "you don't show the schedule", then "you didn't add - anything" -- about blocks that had in fact been added. - """ - lines: list[str] = [] - for event in list(getattr(timebox, "events", None) or []): - title = (getattr(event, "summary", None) or "Untitled block").strip() - start = getattr(event, "start_time", None) - end = getattr(event, "end_time", None) - if start and end: - lines.append(f"{start.strftime('%H:%M')}-{end.strftime('%H:%M')} {title}") - elif start: - lines.append(f"{start.strftime('%H:%M')} {title}") - else: - lines.append(title) - return lines - - def _format_stage_message( - self, - gate: StageGateOutput, - *, - background_notes: list[str] | None = None, - constraints: list[Constraint] | None = None, - immovables: list[dict[str, str]] | None = None, - timebox: Timebox | None = None, - ) -> str: - """Render a markdown stage update, preferring structured section payloads.""" - if gate.response_message and gate.response_message.sections: - message = gate.response_message - if gate.stage_id == TimeboxingStage.REFINE: - # Rendered unconditionally from the plan, never by inspecting - # what the stage model chose to write. Asking whether its prose - # "already showed the schedule" would be a judgement about text, - # and the answer was wrong five times out of five. - schedule_lines = self._format_schedule_lines(timebox) - if schedule_lines: - message = SessionMessage( - sections=[ - FreeformSection( - heading="Schedule", - content="\n".join( - f"- {line}" for line in schedule_lines - ), - ), - *message.sections, - ] - ) - ordered = self._apply_stage_response_contract( - gate=gate, message=message - ) - rendered = self._render_session_message(ordered) - if rendered.strip(): - return rendered - - header = self._stage_label(gate.stage_id) - summary_lines = self._sanitize_stage_summary_lines( - gate=gate, immovables=immovables - ) - bullets = ( - "\n".join([f"- {b}" for b in summary_lines]) - if summary_lines - else "- (none)" - ) - missing = ( - "\n".join([f"- {m}" for m in gate.missing]) if gate.missing else "- (none)" - ) - question = (gate.question or "").strip() or ( - "Share the missing inputs, then reply in thread with `Redo`." - if not gate.ready - else "Confirm this plan or share any changes." - ) - status_line = ( - "Stage criteria met. You can proceed or share adjustments." - if gate.ready - else "Stage criteria not met. Share missing inputs, then reply with `Redo`." - ) - sections: list[NextStepsSection | ConstraintsSection | FreeformSection] = [ - NextStepsSection( - heading="What I need from you", content=[question, status_line] - ), - FreeformSection(heading="Current step", content=header), - ] - if gate.stage_id == TimeboxingStage.COLLECT_CONSTRAINTS: - defaults_raw = [] - if isinstance(gate.facts, dict): - defaults_raw = list(gate.facts.get("defaults_applied") or []) - defaults = [ - f"- {line.strip()}" - for line in defaults_raw - if isinstance(line, str) and line.strip() - ] - defaults_block = "\n".join(defaults) if defaults else "- (none)" - sections.extend( - [ - FreeformSection( - heading="Confirmed defaults", content=defaults_block - ), - FreeformSection(heading="Still missing", content=missing), - FreeformSection(heading="What I have so far", content=bullets), - ] - ) - if constraints: - top_constraints = self._format_constraints_section(constraints, limit=3) - all_constraints = ( - self._format_constraints_section(constraints, limit=100) - if len(constraints) > 3 - else [] - ) - sections.append( - ConstraintsSection( - heading=f"Constraints (top {min(3, len(constraints))}/{len(constraints)})", - content=top_constraints, - folded_content=all_constraints, - ) - ) - assumption_lines = self._format_assumptions_section(constraints) - if assumption_lines: - sections.append( - FreeformSection( - heading="Assumptions currently applied (yes-state; deny/edit if wrong)", - content="\n".join( - [f"- {line}" for line in assumption_lines] - ), - ) - ) - if immovables: - immovable_lines = self._format_immovables_section(immovables) - sections.append( - FreeformSection( - heading="Calendar", - content="\n".join([f"- {line}" for line in immovable_lines]), - ) - ) - if background_notes: - notes = "\n".join([f"- {note}" for note in background_notes]) - sections.append(FreeformSection(heading="Background", content=notes)) - return self._render_session_message( - self._apply_stage_response_contract( - gate=gate, message=SessionMessage(sections=sections) - ) - ) - - if not gate.ready: - sections.extend( - [ - FreeformSection(heading="Need Before Proceeding:", content=missing), - FreeformSection(heading="What I Have So Far:", content=bullets), - ] - ) - else: - sections.append(FreeformSection(heading="Summary", content=bullets)) - if constraints: - top_constraints = self._format_constraints_section(constraints, limit=3) - all_constraints = ( - self._format_constraints_section(constraints, limit=100) - if len(constraints) > 3 - else [] - ) - sections.append( - ConstraintsSection( - heading=f"Constraints (top {min(3, len(constraints))}/{len(constraints)})", - content=top_constraints, - folded_content=all_constraints, - ) - ) - assumption_lines = self._format_assumptions_section(constraints) - if assumption_lines: - sections.append( - FreeformSection( - heading="Assumptions currently applied (yes-state; deny/edit if wrong)", - content="\n".join([f"- {line}" for line in assumption_lines]), - ) - ) - if immovables: - immovable_lines = self._format_immovables_section(immovables) - sections.append( - FreeformSection( - heading="Calendar", - content="\n".join([f"- {line}" for line in immovable_lines]), - ) - ) - if background_notes: - notes = "\n".join([f"- {note}" for note in background_notes]) - sections.append(FreeformSection(heading="Background", content=notes)) - return self._render_session_message( - self._apply_stage_response_contract( - gate=gate, message=SessionMessage(sections=sections) - ) - ) - - def _collect_background_notes(self, session: Session) -> list[str] | None: - """Assemble background status notes to include in stage responses.""" - notes: list[str] = [] - - def _add(note: str) -> None: - if note in notes: - return - notes.append(note) - - if session.pending_durable_constraints: - _add("Loading saved constraints...") - if session.pending_calendar_prefetch: - _add("Loading calendar immovables for the day.") - if session.pending_constraint_extractions: - _add("Syncing your preferences in the background so we can keep moving.") - if session.pending_skeleton_pre_generation: - _add("Pre-drafting your skeleton in the background.") - if session.background_updates: - for note in session.background_updates: - _add(note) - session.background_updates.clear() - return notes or None - - async def _advance_stage( - self, session: Session, *, next_stage: TimeboxingStage - ) -> None: - """Advance the session to the next stage and reset stage state.""" - session.stage = next_stage - session.stage_ready = False - session.stage_missing = [] - session.stage_question = None - if next_stage != TimeboxingStage.REVIEW_COMMIT: - session.pending_submit = False - session.pending_presenter_blocks = None - if next_stage in ( - TimeboxingStage.COLLECT_CONSTRAINTS, - TimeboxingStage.CAPTURE_INPUTS, - ): - session.timebox = None - session.skeleton_overview_markdown = None - task = session.pre_generated_skeleton_task - if task and not task.done(): - task.cancel() - session.pre_generated_skeleton = None - session.pre_generated_skeleton_markdown = None - session.pre_generated_skeleton_fingerprint = None - session.pre_generated_skeleton_task = None - session.pending_skeleton_pre_generation = False - - @staticmethod - def _previous_stage(stage: TimeboxingStage) -> TimeboxingStage: - """Return the previous stage for the timeboxing flow.""" - prev_map = { - TimeboxingStage.CAPTURE_INPUTS: TimeboxingStage.COLLECT_CONSTRAINTS, - TimeboxingStage.SKELETON: TimeboxingStage.CAPTURE_INPUTS, - TimeboxingStage.REFINE: TimeboxingStage.SKELETON, - TimeboxingStage.REVIEW_COMMIT: TimeboxingStage.REFINE, - } - return prev_map.get(stage, TimeboxingStage.COLLECT_CONSTRAINTS) - - async def _proceed(self, session: Session) -> None: - """Advance the session to the next stage.""" - next_map = { - TimeboxingStage.COLLECT_CONSTRAINTS: TimeboxingStage.CAPTURE_INPUTS, - TimeboxingStage.CAPTURE_INPUTS: TimeboxingStage.SKELETON, - TimeboxingStage.SKELETON: TimeboxingStage.REFINE, - TimeboxingStage.REFINE: TimeboxingStage.REVIEW_COMMIT, - TimeboxingStage.REVIEW_COMMIT: TimeboxingStage.REVIEW_COMMIT, - } - next_stage = next_map.get(session.stage, session.stage) - await self._advance_stage(session, next_stage=next_stage) - - def _build_constraint_agent(self) -> "AssistantAgent": - """Build the LLM agent that extracts local constraints.""" - model_client = getattr(self, "_constraint_model_client", None) or getattr( - self, "_model_client", None - ) - if model_client is None: - raise RuntimeError("Constraint model client is not configured.") - return AssistantAgent( - name="ConstraintExtractor", - model_client=model_client, - output_content_type=ConstraintBatch, - system_message=( - "Extract ONLY explicit scheduling preferences or constraints that the USER personally stated. " - "Examples of valid constraints:\n" - "- 'I have a meeting at 2pm' -> fixed appointment\n" - "- 'I don't work before 9am' -> work window preference\n" - "- 'I want 2 deep-work blocks' -> block allocation preference\n" - "- 'I need 2 hours for deep work' -> duration requirement\n" - "- 'I want to exercise in the morning' -> activity preference\n" - "- 'No calls after 5pm' -> availability rule\n\n" - "DO NOT extract:\n" - "- Generic statements about timeboxing or scheduling methodology\n" - "- Definitions or explanations of what timeboxing means\n" - "- Bot/system messages or instructions\n" - "- Anything the user did NOT explicitly state as their own preference\n\n" - "If no valid user constraints are found, return an empty constraints list.\n" - "Return ONLY a JSON object with a list of constraints. Each constraint needs " - "name, description, necessity (must/should/prefer), and any useful hints/selector " - "metadata. Use source=user and status=proposed unless explicitly locked." - ), - reflect_on_tool_use=False, - max_tool_iterations=1, - ) - - def _build_constraint_search_tool(self) -> FunctionTool: - """Build a FunctionTool that lets stage-gating LLMs search durable constraints. - - The tool closes over ``self`` so it can lazily initialise the MCP client. - """ - agent_ref = self # prevent gc issues with the closure - - async def _search_constraints_wrapper( - queries: list[ConstraintSearchQuery], - planned_date: str | None, - stage: str | None, - ) -> str: - """Search the durable constraint store with one or more query facets. - - Use this tool to find saved scheduling preferences and constraints - from the durable preference store. You can search by: - - text (free-text match on constraint name or description) - - event type codes (M, DW, SW, H, R, C, BU, BG, PR) - - topic tags - - status (locked / proposed) - - scope (session / profile / datespan) - - necessity (must / should / prefer) - - Args: - queries: List of search facets. Each facet is a dict with keys: - - label (str): Short description of this query. - - text_query (str): Free-text search on Name/Description. - - event_types (list[str]): Event-type codes. - - tags (list[str]): Topic tag names. - - statuses (list[str]): 'locked' and/or 'proposed'. - - scopes (list[str]): 'session', 'profile', 'datespan'. - - necessities (list[str]): 'must', 'should', and/or 'prefer'. - - limit (int): Max results per facet (default 20). - planned_date: ISO date (YYYY-MM-DD), or null to use today. - stage: Current timeboxing stage, or null for no stage filter. - - Returns: - Formatted summary of matching constraints. - """ - query_payloads = [ - query.model_dump( - mode="json", - exclude_none=True, - exclude_defaults=True, - ) - for query in queries - ] - semantic_query_payloads = [ - { - key: value - for key, value in payload.items() - if key not in {"label", "limit"} - } - for payload in query_payloads - ] - if stage == TimeboxingStage.COLLECT_CONSTRAINTS.value and ( - not semantic_query_payloads - or all(not payload for payload in semantic_query_payloads) - ): - return ( - "Skipped search_constraints for Stage 1 because no concrete query " - "facet was provided. Using deterministic saved-default prefetch." - ) - client = agent_ref._ensure_durable_constraint_store() - return await search_constraints( - queries=query_payloads, - planned_date=planned_date, - stage=stage, - _client=client, - ) - - return FunctionTool( - _search_constraints_wrapper, - name="search_constraints", - description=( - "Search the durable constraint/preference store. " - "Accepts one or more search facets (text, event types, tags, " - "status, scope, necessity) and returns a formatted summary of " - "matching constraints. Use this to find the user's saved " - "scheduling preferences before making planning decisions." - ), - strict=True, - ) - - async def _ensure_constraint_store(self) -> None: - """Initialize the SQLite constraint store if needed.""" - if self._constraint_store or not settings.database_url: - return - async_url = _coerce_async_database_url(settings.database_url) - engine = create_async_engine(async_url) - await ensure_constraint_schema(engine) - sessionmaker = async_sessionmaker(engine, expire_on_commit=False) - self._constraint_store = ConstraintStore(sessionmaker) - self._constraint_engine = engine - - async def _extract_constraints( - self, - session: Session, - text: str, - *, - scope_override: ConstraintScope | None = None, - ) -> ConstraintBatch | None: - """Extract session constraints and persist them in the local store.""" - if not text.strip(): - return None - await self._ensure_constraint_store() - message = TextMessage(content=text, source="user") - response = await with_timeout( - "timeboxing:constraint-extract", - self._constraint_agent.on_messages([message], CancellationToken()), - timeout_s=TIMEBOXING_TIMEOUTS.constraint_extract_s, - ) - batch = _extract_constraint_batch(response) - if not batch or not batch.constraints: - return None - if scope_override: - for constraint in batch.constraints: - constraint.scope = scope_override - if self._constraint_store: - await self._constraint_store.add_constraints( - user_id=session.user_id, - channel_id=session.channel_id, - thread_ts=session.thread_ts, - constraints=batch.constraints, - ) - await self._collect_constraints(session) - return batch - - async def _update_timebox_with_feedback( - self, session: Session, text: str - ) -> List[TimeboxAction]: - """Apply user feedback to the current timebox draft.""" - if session.stage != TimeboxingStage.REFINE: - logger.warning( - "Ignoring patch request outside Stage 4 Refine: stage=%s", - session.stage.value if session.stage else None, - ) - return [] - if session.timebox is None and session.tb_plan is None: - return [] - before = session.timebox or Timebox.model_construct( - events=[], - date=self._resolve_planning_date(session), - timezone=session.tz_name or "UTC", - ) - constraints = await self._collect_constraints(session) - patch_constraints = self._select_constraints_for_refine_patcher( - session=session, - constraints=constraints, - ) - - # Use TBPlan path if available, fall back to legacy Timebox path - if session.tb_plan is not None: - validated_timebox: Timebox | None = None - - def _materialize_timebox(plan: TBPlan) -> Timebox: - nonlocal validated_timebox - validated_timebox = tb_plan_to_timebox(plan) - return validated_timebox - - patch_message = self._compose_patcher_message( - base_message=text, - session=session, - stage=TimeboxingStage.REFINE.value, - extra={"quality_snapshot": self._quality_snapshot_for_prompt(session)}, - ) - patched_plan, _patch = await self._timebox_patcher.apply_patch( - stage=TimeboxingStage.REFINE.value, - current=session.tb_plan, - user_message=patch_message, - constraints=patch_constraints, - actions=[], - plan_validator=_materialize_timebox, - ) - session.tb_plan = patched_plan - if validated_timebox is None: - raise ValueError( - "Patch validated without producing a materialized Timebox." - ) - session.timebox = validated_timebox - else: - session.timebox = await self._timebox_patcher.apply_patch_legacy( - stage=TimeboxingStage.REFINE.value, - current=session.timebox, - user_message=text, - constraints=patch_constraints, - actions=[], - ) - - session.last_user_message = text - actions = _build_actions( - before, session.timebox, reason=text, constraints=constraints - ) - return actions - - async def _collect_constraints(self, session: Session) -> list[Constraint]: - """Return combined durable + session constraints and cache them on the session.""" - local_constraints: list[Constraint] = [] - shared_stats: dict[str, Any] = {} - stage_order = self._active_durable_stage_order(session.stage) - relevant_stage_keys = ( - stage_order - if stage_order - else tuple(session.durable_constraints_by_stage.keys()) - ) - include_shared_scopes = self._should_include_local_shared_constraints( - session=session, - relevant_stage_keys=relevant_stage_keys, - ) - if self._constraint_store: - stats_getter = getattr(self._constraint_store, "shared_scope_stats", None) - if callable(stats_getter): - try: - shared_stats = await stats_getter(user_id=session.user_id) - except Exception: - shared_stats = {} - local_constraints = await self._constraint_store.list_constraints( - user_id=session.user_id, - channel_id=session.channel_id, - thread_ts=session.thread_ts, - include_shared_scopes=include_shared_scopes, - ) - local_raw_count = len(local_constraints or []) - local_constraints = self._filter_local_constraints_for_relevance( - session=session, - constraints=list(local_constraints or []), - ) - local_shared_scope_count = sum( - 1 - for constraint in (local_constraints or []) - if constraint.scope in (ConstraintScope.PROFILE, ConstraintScope.DATESPAN) - and (constraint.thread_ts or "") != (session.thread_ts or "") - ) - local_declined_uids = { - uid - for constraint in (local_constraints or []) - if constraint.status == ConstraintStatus.DECLINED - for uid in [self._constraint_uid(constraint)] - if uid - } - if local_declined_uids: - session.suppressed_durable_uids.update(local_declined_uids) - durable_constraints = [ - constraint - for stage_key in relevant_stage_keys - for constraint in ( - session.durable_constraints_by_stage.get(stage_key) or [] - ) - if (uid := self._constraint_uid(constraint)) is None - or uid not in session.suppressed_durable_uids - ] - combined = _dedupe_constraints( - list(local_constraints or []) + durable_constraints - ) - raw_active_constraints = [ - c for c in combined if c.status != ConstraintStatus.DECLINED - ] - applicable_active_constraints = [ - constraint - for constraint in raw_active_constraints - if _constraint_is_applicable_for_session( - constraint, - planned_date=session.planned_date, - stage=session.stage, - ) - ] - selected_active_constraints = self._reconcile_constraints_for_stage_context( - session=session, - constraints=applicable_active_constraints, - ) - session.active_constraints = selected_active_constraints - session.active_constraints_raw_count = len(raw_active_constraints) - session.active_constraints_applicable_count = len( - applicable_active_constraints - ) - session.active_constraints_selected_count = len(selected_active_constraints) - self._session_debug( - session, - "constraints_active_snapshot", - stage=session.stage.value if session.stage else None, - include_shared_scopes=include_shared_scopes, - local_raw_count=local_raw_count, - local_count=len(local_constraints or []), - local_shared_scope_count=local_shared_scope_count, - local_relevance_filtered=max( - 0, int(local_raw_count) - int(len(local_constraints or [])) - ), - local_shared_raw_rows=int(shared_stats.get("raw_shared_rows") or 0), - local_shared_canonical_rows=int( - shared_stats.get("canonical_shared_rows") or 0 - ), - local_shared_duplicate_groups=int(shared_stats.get("duplicate_groups") or 0), - durable_count=len(durable_constraints), - active_raw_count=len(raw_active_constraints), - active_applicable_count=len(applicable_active_constraints), - active_selected_count=len(selected_active_constraints), - active_filtered_out_count=max( - 0, len(raw_active_constraints) - len(applicable_active_constraints) - ), - durable_stage_keys=list(relevant_stage_keys), - top_names=[ - (constraint.name or "").strip() - for constraint in session.active_constraints[:10] - if (constraint.name or "").strip() - ], - ) - return list(session.active_constraints or []) - - def _reconcile_constraints_for_stage_context( - self, - *, - session: Session, - constraints: list[Constraint], - ) -> list[Constraint]: - """Reconcile duplicate families and keep stage-relevant constraints only.""" - stage = session.stage - session_aspect_ids = self._collect_session_aspect_ids(constraints) - reconciled_groups: dict[str, list[Constraint]] = {} - for constraint in constraints or []: - family_key = self._constraint_relevance_family_key(constraint) - reconciled_groups.setdefault(family_key, []).append(constraint) - - reconciled: list[Constraint] = [] - for family_constraints in reconciled_groups.values(): - selected = min( - family_constraints, - key=self._constraint_rank_for_stage_reconciliation, - ) - reconciled.append(selected) - - if stage in (TimeboxingStage.REFINE, TimeboxingStage.REVIEW_COMMIT): - return sorted(reconciled, key=_constraint_priority) - - relevant = [ - constraint - for constraint in reconciled - if self._is_stage_relevant_constraint( - constraint=constraint, - stage=stage, - session_aspect_ids=session_aspect_ids, - ) - ] - if relevant: - return sorted(relevant, key=_constraint_priority) - return sorted(reconciled, key=_constraint_priority) - - @staticmethod - def _collect_session_aspect_ids(constraints: list[Constraint]) -> set[str]: - """Collect aspect IDs explicitly introduced in the current session.""" - aspect_ids: set[str] = set() - for constraint in constraints or []: - if constraint.scope != ConstraintScope.SESSION: - continue - aspect_id = TimeboxingFlowAgent._constraint_aspect_id(constraint) - if aspect_id: - aspect_ids.add(aspect_id) - return aspect_ids - - @staticmethod - def _constraint_rank_for_stage_reconciliation( - constraint: Constraint, - ) -> tuple[int, int, int, str, float, float]: - """Rank candidates inside one relevance family.""" - priority = _constraint_priority(constraint) - scope_rank = _constraint_scope_rank(constraint.scope) - updated_at = constraint.updated_at.timestamp() if constraint.updated_at else 0.0 - created_at = constraint.created_at.timestamp() if constraint.created_at else 0.0 - return ( - priority[0], - priority[1], - scope_rank, - priority[2], - -updated_at, - -created_at, - ) - - @staticmethod - def _constraint_relevance_family_key(constraint: Constraint) -> str: - """Build a stage-reconciliation family key for shared constraints.""" - hints = constraint.hints if isinstance(constraint.hints, dict) else {} - uid = str(hints.get("uid") or "").strip().lower() - if uid: - return f"uid:{uid}" - aspect = hints.get("aspect_classification") - aspect_id = TimeboxingFlowAgent._constraint_aspect_id(constraint) - if aspect_id and isinstance(aspect, dict): - slot = str(aspect.get("frame_slot") or "").strip().lower() - schedule_start = str(aspect.get("schedule_start") or "").strip() - schedule_end = str(aspect.get("schedule_end") or "").strip() - return f"aspect:{aspect_id}:{slot}:{schedule_start}:{schedule_end}" - signature = _constraint_name_signature(constraint.name or "") - if signature: - return f"name:{signature}" - return _constraint_identity_key(constraint) - - @staticmethod - def _constraint_aspect_id(constraint: Constraint) -> str: - """Extract normalized aspect id from a constraint when available.""" - hints = constraint.hints if isinstance(constraint.hints, dict) else {} - aspect = hints.get("aspect_classification") - if not isinstance(aspect, dict): - return "" - return str(aspect.get("aspect_id") or "").strip().lower() - - @staticmethod - def _is_stage_relevant_constraint( - *, - constraint: Constraint, - stage: TimeboxingStage, - session_aspect_ids: set[str] | None = None, - ) -> bool: - """Return whether a reconciled constraint is relevant for this stage.""" - if constraint.scope == ConstraintScope.SESSION: - return True - - hints = constraint.hints if isinstance(constraint.hints, dict) else {} - aspect = hints.get("aspect_classification") - aspect_id = TimeboxingFlowAgent._constraint_aspect_id(constraint) - if isinstance(aspect, dict) and ( - aspect_id - or str(aspect.get("frame_slot") or "").strip() - or str(aspect.get("schedule_start") or "").strip() - or aspect.get("duration_min") is not None - ): - # Aspect is active in this session (e.g. user mentioned a market visit). - if aspect_id and session_aspect_ids and aspect_id in session_aspect_ids: - return True - # Fixed daily slot (morning ritual, gym slot) β€” always include. - if str(aspect.get("frame_slot") or "").strip(): - return True - # Startup-prefetch constraints β€” always include at session open. - if aspect.get("is_startup_prefetch"): - return True - # Has aspect_id but that aspect is absent from this session β†’ exclude. - # (e.g. "Market opening hours" when no market visit is planned.) - if aspect_id: - return False - # No aspect_id β€” generic schedule/duration metadata; include. - return bool( - str(aspect.get("schedule_start") or "").strip() - or str(aspect.get("schedule_end") or "").strip() - or aspect.get("duration_min") is not None - ) - - if constraint.necessity == ConstraintNecessity.MUST and constraint.status in ( - ConstraintStatus.LOCKED, - ConstraintStatus.PROPOSED, - ): - return True - - if stage == TimeboxingStage.COLLECT_CONSTRAINTS: - timing_keys = ( - "start_time", - "end_time", - "wake_time", - "wake", - "bed_time", - "bedtime", - ) - return any(str(hints.get(key) or "").strip() for key in timing_keys) - return False - - def _should_include_local_shared_constraints( - self, *, session: Session, relevant_stage_keys: tuple[str, ...] - ) -> bool: - """Decide whether shared local constraints should be queried for this turn.""" - backend = str(getattr(settings, "timeboxing_memory_backend", "") or "").strip().lower() - if backend != "graphiti": - return True - if session.pending_durable_constraints: - return False - for stage_key in relevant_stage_keys: - if session.durable_constraints_by_stage.get(stage_key): - return False - return True - - def _filter_local_constraints_for_relevance( - self, *, session: Session, constraints: list[Constraint] - ) -> list[Constraint]: - """Keep local constraints that apply on the planned day. - - Filtering is by *applicability* only: a constraint the user declined, - or one scoped to days that are not this one, does not apply. Nothing - else is dropped here. - - This used to also require a PROFILE-scoped constraint to carry at - least one of five incidental fields -- an aspect classification, a - frame slot, LOCKED status, a startup-prefetch tag, or a date window -- - and silently discarded it otherwise. That is a proxy for importance - that has nothing to do with importance: it dropped a MUST the user - stated plainly because the row lacked a classification blob, while - keeping a lesser rule that happened to have one. - - It was also a *second* bound on the same overflow that - `_select_constraints_for_refine_patcher` already handles -- and that - one is principled, ranking by necessity and status and keeping the top - `refine_patcher_constraint_limit`. Two bounds, the outer one cruder - than the inner, and the outer one running first. - - The trap it set: constraints derived by the memory server carry none of - those four non-date fields. `status` is hardcoded PROPOSED so LOCKED is - unreachable, there are no `hints`, and `frame_slot` is null on 94% of - rows. Measured on the legacy store 66 of 97 PROFILE constraints - survived this gate, saved by exactly the fields the new store does not - populate -- so as the corpus moved across, an increasing share of the - user's own rules vanished before any model saw them, with nothing - raised and nothing logged. - """ - if not constraints: - return [] - planned_day = self._parse_session_planned_day(session) - out: list[Constraint] = [] - declined = 0 - wrong_day = 0 - for constraint in constraints: - if constraint.scope not in ( - ConstraintScope.PROFILE, - ConstraintScope.DATESPAN, - ): - out.append(constraint) - continue - if constraint.status == ConstraintStatus.DECLINED: - declined += 1 - continue - if not self._constraint_matches_planned_day(constraint, planned_day): - wrong_day += 1 - continue - out.append(constraint) - if declined or wrong_day: - # Say what was dropped. Every constraint that disappears here is one - # the user stated and will not see honoured, and the previous - # version of this filter discarded rows without a word. - self._session_debug( - session, - "local_constraints_filtered", - received=len(constraints), - kept=len(out), - dropped_declined=declined, - dropped_wrong_day=wrong_day, - planned_day=str(planned_day) if planned_day else None, - ) - return out - - def _select_constraints_for_refine_patcher( - self, - *, - session: Session, - constraints: list[Constraint], - ) -> list[Constraint]: - """Keep Stage 4 patching bounded by selecting the highest-priority constraints.""" - ranked = sorted(constraints or [], key=_constraint_priority) - limit = max(1, TIMEBOXING_LIMITS.refine_patcher_constraint_limit) - if len(ranked) <= limit: - session.last_refine_selected_constraints_count = len(ranked) - session.last_refine_dropped_constraints_count = 0 - self._session_debug( - session, - "refine_constraints_selected", - total=len(ranked), - selected=len(ranked), - dropped=0, - limit=limit, - ) - return ranked - - must_constraints = [ - constraint - for constraint in ranked - if constraint.necessity == ConstraintNecessity.MUST - ] - selected: list[Constraint] = [] - seen: set[str] = set() - for constraint in [*must_constraints, *ranked]: - key = _constraint_identity_key(constraint) - if key in seen: - continue - seen.add(key) - selected.append(constraint) - if len(selected) >= limit: - break - - session.last_refine_selected_constraints_count = len(selected) - session.last_refine_dropped_constraints_count = max( - 0, len(ranked) - len(selected) - ) - self._session_debug( - session, - "refine_constraints_selected", - total=len(ranked), - selected=len(selected), - dropped=max(0, len(ranked) - len(selected)), - limit=limit, - selected_names=[ - (constraint.name or "").strip() - for constraint in selected[:10] - if (constraint.name or "").strip() - ], - ) - return selected - - async def _sync_durable_constraints_to_store( - self, session: Session, *, constraints: list[Constraint] - ) -> None: - """Mirror durable constraints into the local store for Slack display.""" - if not constraints: - return - await self._ensure_constraint_store() - if not self._constraint_store: - return - existing = await self._constraint_store.list_constraints( - user_id=session.user_id, - channel_id=session.channel_id, - thread_ts=session.thread_ts, - include_shared_scopes=True, - ) - existing_keys = {_constraint_identity_key(c) for c in existing} - to_add: list[ConstraintBase] = [] - for constraint in constraints: - key = _constraint_identity_key(constraint) - if key in existing_keys: - continue - payload = constraint.model_dump( - exclude={ - "id", - "user_id", - "channel_id", - "thread_ts", - "created_at", - "updated_at", - } - ) - to_add.append(ConstraintBase.model_validate(payload)) - upsert_added = 0 - upsert_skipped = 0 - if to_add: - persisted_rows = await self._constraint_store.add_constraints( - user_id=session.user_id, - channel_id=session.channel_id, - thread_ts=session.thread_ts, - constraints=to_add, - ) - prune_result: dict[str, Any] = {} - pruner = getattr(self._constraint_store, "prune_shared_constraints", None) - if callable(pruner): - try: - prune_result = await pruner(user_id=session.user_id, dry_run=False) - except Exception: - prune_result = {} - self._session_debug( - session, - "local_constraint_sync", - mirrored_incoming=len(to_add), - mirrored_persisted=len(persisted_rows or []), - shared_raw_rows=int(prune_result.get("raw_shared_rows") or 0), - shared_canonical_rows=int( - prune_result.get("canonical_shared_rows") or 0 - ), - shared_duplicate_groups=int(prune_result.get("duplicate_groups") or 0), - shared_duplicates_archived=int( - prune_result.get("duplicates_archived") or 0 - ), - ) - - async def _publish_update( - self, - *, - session: Session, - user_message: str, - actions: List[TimeboxAction], - ) -> None: - """Publish a TimeboxingUpdate message to Slack subscribers.""" - await self.publish_message( - TimeboxingUpdate( - thread_ts=session.thread_ts, - channel_id=session.channel_id, - user_id=session.user_id, - user_message=user_message, - constraints=session.active_constraints, - timebox=session.timebox, - actions=actions, - patch_history=[], - ), - DefaultTopicId(), - ) - - async def _maybe_wrap_constraint_review( - self, *, reply: TextMessage, session: Session - ) -> TextMessage | SlackBlockMessage: - """Optionally wrap a reply with the constraint review UI when new proposals exist.""" - task = session.last_extraction_task - if not task or not task.done(): - return reply - try: - extracted = task.result() - except Exception: - return reply - if not extracted: - return reply - await self._ensure_constraint_store() - constraints: list[Constraint] = [] - if self._constraint_store: - constraints = await self._constraint_store.list_constraints( - user_id=session.user_id, - channel_id=session.channel_id, - thread_ts=session.thread_ts, - status=ConstraintStatus.PROPOSED, - ) - if not constraints: - return reply - return _wrap_with_constraint_review( - reply, constraints=constraints, session=session - ) - - def _attach_presenter_blocks( - self, - *, - reply: TextMessage | SlackBlockMessage, - session: Session, - ) -> TextMessage | SlackBlockMessage: - """Attach pending presenter blocks to the outgoing Slack payload.""" - presenter_blocks = list(session.pending_presenter_blocks or []) - session.pending_presenter_blocks = None - has_constraint_preview = False - if isinstance(reply, SlackBlockMessage): - for block in reply.blocks: - accessory = block.get("accessory") if isinstance(block, dict) else None - if ( - isinstance(accessory, dict) - and accessory.get("action_id") == CONSTRAINT_ROW_REVIEW_ACTION_ID - ): - has_constraint_preview = True - break - if block.get("type") == "actions": - elements = block.get("elements") if isinstance(block, dict) else [] - if any( - isinstance(element, dict) - and element.get("action_id") == CONSTRAINT_REVIEW_ALL_ACTION_ID - for element in (elements or []) - ): - has_constraint_preview = True - break - constraint_blocks = ( - [] - if has_constraint_preview - else self._render_constraints_preview_blocks(session=session) - ) - submit_blocks: list[dict[str, Any]] = [] - match session.stage: - case TimeboxingStage.REVIEW_COMMIT: - if ( - session.stage_ready - and session.tb_plan is not None - and session.base_snapshot is not None - ): - session.pending_submit = True - # Auto-submit will trigger immediately after this inside on_user_reply - submit_blocks = [] - else: - session.pending_submit = False - case _: - session.pending_submit = False - stage_blocks = self._render_stage_action_blocks(session=session) - combined_blocks = [ - *presenter_blocks, - *constraint_blocks, - *submit_blocks, - *stage_blocks, - ] - if not combined_blocks: - return reply - if isinstance(reply, SlackBlockMessage): - return SlackBlockMessage( - text=reply.text, - blocks=list(reply.blocks) + combined_blocks, - ) - return SlackBlockMessage( - text=reply.content, - blocks=[build_markdown_block(text=reply.content), *combined_blocks], - ) - - @staticmethod - def _interaction_mode(session: Session) -> InteractionMode: - """Infer the interaction mode for response serialization.""" - if (session.channel_id or "").strip(): - return InteractionMode.SLACK - return InteractionMode.TEXT - - @staticmethod - def _append_presenter_blocks( - session: Session, blocks: list[dict[str, Any]] - ) -> None: - """Append blocks without overwriting previously queued presenter content.""" - if not blocks: - return - existing = list(session.pending_presenter_blocks or []) - session.pending_presenter_blocks = [*existing, *blocks] - - def _record_memory_tool_result( - self, - *, - session: Session, - result: MemoryToolResult, - ) -> dict[str, Any]: - """Store/render one typed tool result and return tool-transport payload.""" - presentation = present_memory_tool_result( - result=result, - context=InteractionContext( - mode=self._interaction_mode(session), - user_id=session.user_id, - thread_ts=session.thread_ts, - ), - ) - self._append_presenter_blocks(session, presentation.blocks) - if presentation.text_update: - self._append_background_update_once(session, presentation.text_update) - return presentation.payload - - @staticmethod - def _parse_memory_patch_json(patch_json: str) -> tuple[dict[str, Any], str | None]: - """Parse a JSON object payload used by memory update/supersede tools.""" - cleaned = str(patch_json or "").strip() - if not cleaned: - return {}, "Memory patch payload cannot be empty." - try: - parsed = TypeAdapter(dict[str, Any]).validate_json(cleaned) - except ValidationError: - return {}, "Memory patch payload must be a valid JSON object string." - except (TypeError, ValueError): - return {}, "Memory patch payload must be a valid JSON object string." - return parsed, None - - # endregion - - @message_handler - async def on_start( - self, message: StartTimeboxing, ctx: MessageContext - ) -> TextMessage | SlackBlockMessage: - """Handle the initial StartTimeboxing signal.""" - key = self._session_key(ctx, fallback=message.thread_ts) - logger.info("Starting timeboxing session on key=%s", key) - timeboxing_activity.mark_active( - user_id=message.user_id, - channel_id=message.channel_id, - thread_ts=message.thread_ts, - ) - tz_name = self._resolve_tz_name(self._default_tz_name()) - now_utc = datetime.now(timezone.utc) - default_planned_date = self._default_planned_date( - now=now_utc, - tz=ZoneInfo(tz_name), - ) - session, created = self._ensure_uncommitted_session( - key=key, - thread_ts=message.thread_ts, - channel_id=message.channel_id, - user_id=message.user_id, - user_input=message.user_input, - tz_name=tz_name, - default_planned_date=default_planned_date, - debug_event="session_started", - start_message=message.user_input, - ) - - planned_date = await self._interpret_planned_date( - message.user_input, - now=now_utc, - tz_name=tz_name, - ) - if not session.committed: - session.planned_date = planned_date - if created: - asyncio.create_task( - self._prefetch_calendar_immovables(session, planned_date) - ) - self._queue_constraint_prefetch(session) - return self._build_commit_prompt_blocks(session=session) - - @message_handler - async def on_commit_date( - self, message: TimeboxingCommitDate, ctx: MessageContext - ) -> TextMessage | SlackBlockMessage: - """Handle Stage 0 commit actions from Slack.""" - key = self._session_key(ctx, fallback=message.thread_ts) - timeboxing_activity.mark_active( - user_id=message.user_id, - channel_id=message.channel_id, - thread_ts=message.thread_ts, - ) - session = self._sessions.get(key) - if not session: - session = Session( - thread_ts=message.thread_ts, - channel_id=message.channel_id, - user_id=message.user_id, - last_user_message="", - session_key=key, - ) - self._sessions[key] = session - elif session.session_key is None: - session.session_key = key - - session.committed = True - session.planned_date = message.planned_date - session.tz_name = message.timezone or session.tz_name or "UTC" - self._refresh_temporal_facts(session) - self._session_debug( - session, - "commit_date", - planned_date=message.planned_date, - timezone=session.tz_name, - ) - await self._prime_collect_prefetch_non_blocking( - session=session, - planned_date=message.planned_date, - blocking=True, - ) - - session.thread_state = None - session.last_extraction_task = None - user_message = "" - response = await self._run_graph_turn(session=session, user_text=user_message) - wrapped = await self._maybe_wrap_constraint_review( - reply=response, session=session - ) - outgoing = self._attach_presenter_blocks(reply=wrapped, session=session) - await self._publish_update( - session=session, - user_message=( - outgoing.content - if isinstance(outgoing, TextMessage) - else getattr(outgoing, "text", "") - ), - actions=[], - ) - return outgoing - - @message_handler - async def on_user_reply( - self, message: TimeboxingUserReply, ctx: MessageContext - ) -> TextMessage | SlackBlockMessage | SlackThreadStateMessage: - """Handle user replies within an active timeboxing session.""" - key = self._session_key(ctx, fallback=message.thread_ts) - timeboxing_activity.mark_active( - user_id=message.user_id, - channel_id=message.channel_id, - thread_ts=message.thread_ts, - ) - session = self._sessions.get(key) - if not session: - tz_name = self._resolve_tz_name(self._default_tz_name()) - now_utc = datetime.now(timezone.utc) - default_planned_date = self._default_planned_date( - now=now_utc, - tz=ZoneInfo(tz_name), - ) - session, _ = self._ensure_uncommitted_session( - key=key, - thread_ts=message.thread_ts, - channel_id=message.channel_id, - user_id=message.user_id, - user_input=message.text, - tz_name=tz_name, - default_planned_date=default_planned_date, - debug_event="session_started_from_reply", - ) - if session.session_key is None: - session.session_key = key - async with session.reply_turn_lock: - self._session_debug( - session, - "user_reply", - text=(message.text or "")[:800], - committed=session.committed, - ) - - was_committed = session.committed - if not session.committed: - # Thread replies should progress naturally even without explicit button confirmation. - tz_name = self._resolve_tz_name( - session.tz_name or self._default_tz_name() - ) - session.tz_name = tz_name - planned_date = await self._interpret_planned_date( - message.text, - now=datetime.now(timezone.utc), - tz_name=tz_name, - ) - if planned_date != session.planned_date: - session.planned_date = planned_date - self._reset_durable_prefetch_state(session) - resolved_planned_date = session.planned_date or planned_date - session.committed = True - self._session_debug( - session, - "implicit_commit_from_thread_reply", - planned_date=resolved_planned_date, - ) - self._refresh_temporal_facts(session) - await self._prime_collect_prefetch_non_blocking( - session=session, - planned_date=resolved_planned_date, - blocking=True, - ) - # These two are independent and used to run in sequence, which put - # an LLM round-trip behind an I/O round-trip on every reply. The - # prefetch fetches calendar and constraints; the decision reads only - # stage-machine state carried from the previous turn (stage, - # stage_ready, stage_question, stage_missing) plus the user's text, - # and nothing the prefetch writes. Measured on a real session, the - # gap between `user_reply` and `graph_turn_start` was 8-14s before - # the graph's own 43-54s had even begun. CLAUDE.md: independent - # judgements go out concurrently, never in sequence. - prefetch_task = ( - self._scheduler_prefetch.ensure_collect_stage_ready(session=session) - if was_committed - else None - ) - decide_task = ( - self._decide_next_action(session, user_message=message.text) - if session.stage - in ( - TimeboxingStage.SKELETON, - TimeboxingStage.REFINE, - TimeboxingStage.REVIEW_COMMIT, - ) - else None - ) - decision: StageDecision | None = None - if prefetch_task is not None and decide_task is not None: - _, decision = await asyncio.gather(prefetch_task, decide_task) - elif prefetch_task is not None: - await prefetch_task - elif decide_task is not None: - decision = await decide_task - if decision is not None: - if decision.submit_intent: - session.queued_submit_intent = True - elif session.queued_submit_intent and decision.action in ( - "cancel", - "back", - "assist", - ): - session.queued_submit_intent = False - if ( - session.stage == TimeboxingStage.REVIEW_COMMIT - and session.pending_submit - ): - self._session_debug( - session, - "auto_submit_on_review_commit", - decision_action=decision.action if decision else None, - submit_mode="auto_nl", - submit_attempt_kind=self._submit_attempt_kind(session), - ) - submit_reply = await self._submit_pending_plan( - session=session, - submit_mode="auto_nl", - submit_attempt_kind=self._submit_attempt_kind(session), - ) - if not session.pending_submit: - session.queued_submit_intent = False - await self._publish_update( - session=session, - user_message=( - submit_reply.content - if isinstance(submit_reply, TextMessage) - else submit_reply.text - ), - actions=[], - ) - return submit_reply - memory_reply = await self._maybe_handle_memory_review_turn( - session=session, - user_message=message.text, - ) - if memory_reply is not None: - outgoing = self._attach_presenter_blocks( - reply=memory_reply, session=session - ) - await self._publish_update( - session=session, - user_message=( - outgoing.content - if isinstance(outgoing, TextMessage) - else getattr(outgoing, "text", "") - ), - actions=[], - ) - return outgoing - session.thread_state = None - reply = await self._run_graph_turn(session=session, user_text=message.text) - wrapped = await self._maybe_wrap_constraint_review( - reply=reply, session=session - ) - outgoing = self._attach_presenter_blocks(reply=wrapped, session=session) - - if session.stage == TimeboxingStage.REVIEW_COMMIT: - if ( - session.pending_submit - and session.tb_plan is not None - and session.base_snapshot is not None - ): - self._session_debug( - session, - "auto_submit_on_review_commit", - decision_action=decision.action, - submit_mode="auto_nl", - submit_attempt_kind=self._submit_attempt_kind(session), - ) - submit_reply = await self._submit_pending_plan( - session=session, - submit_mode="auto_nl", - submit_attempt_kind=self._submit_attempt_kind(session), - ) - if not session.pending_submit: - session.queued_submit_intent = False - await self._publish_update( - session=session, - user_message=( - submit_reply.content - if isinstance(submit_reply, TextMessage) - else submit_reply.text - ), - actions=[], - ) - return submit_reply - not_ready_reply = self._build_submit_incomplete_reply() - await self._publish_update( - session=session, - user_message=not_ready_reply.content, - actions=[], - ) - return not_ready_reply - await self._publish_update( - session=session, - user_message=( - outgoing.content - if isinstance(outgoing, TextMessage) - else getattr(outgoing, "text", "") - ), - actions=[], - ) - if session.thread_state: - timeboxing_activity.mark_inactive(user_id=session.user_id) - return SlackThreadStateMessage( - text=reply.content, - thread_state=session.thread_state, - ) - return outgoing - - @message_handler - async def on_user_text( - self, message: TextMessage, ctx: MessageContext - ) -> TextMessage | SlackBlockMessage | SlackThreadStateMessage: - """Handle generic text messages routed to the timeboxing agent.""" - key = self._session_key(ctx) - session = self._sessions.get(key) - if not session: - return TextMessage( - content="Let's start by telling me what window you want to plan.", - source=self.id.type, - ) - if session.session_key is None: - session.session_key = key - self._session_debug( - session, - "user_text", - text=(message.content or "")[:800], - committed=session.committed, - ) - if not session.committed: - tz_name = session.tz_name or self._default_tz_name() - try: - ZoneInfo(tz_name) - except Exception: - ZoneInfo("UTC") - tz_name = "UTC" - planned_date = await self._interpret_planned_date( - message.content, - now=datetime.now(timezone.utc), - tz_name=tz_name, - ) - if planned_date != session.planned_date: - self._reset_durable_prefetch_state(session) - session.planned_date = planned_date - session.tz_name = tz_name - self._scheduler_prefetch.queue_initial_prefetch( - session=session, planned_date=planned_date - ) - timeboxing_activity.mark_active( - user_id=session.user_id, - channel_id=session.channel_id, - thread_ts=session.thread_ts, - ) - return self._build_commit_prompt_blocks(session=session) - timeboxing_activity.mark_active( - user_id=session.user_id, - channel_id=session.channel_id, - thread_ts=session.thread_ts, - ) - await self._scheduler_prefetch.ensure_collect_stage_ready(session=session) - session.thread_state = None - reply = await self._run_graph_turn(session=session, user_text=message.content) - wrapped = await self._maybe_wrap_constraint_review(reply=reply, session=session) - outgoing = self._attach_presenter_blocks(reply=wrapped, session=session) - await self._publish_update( - session=session, - user_message=( - outgoing.content - if isinstance(outgoing, TextMessage) - else getattr(outgoing, "text", "") - ), - actions=[], - ) - if session.thread_state: - timeboxing_activity.mark_inactive(user_id=session.user_id) - return SlackThreadStateMessage( - text=reply.content, - thread_state=session.thread_state, - ) - return outgoing - - @message_handler - async def on_stage_action( - self, message: TimeboxingStageAction, ctx: MessageContext - ) -> TextMessage | SlackBlockMessage | SlackThreadStateMessage: - """Handle deterministic stage-control actions from Slack buttons.""" - key = self._session_key(ctx, fallback=message.thread_ts) - session = self._sessions.get(key) - if not session: - return TextMessage( - content="That timeboxing session is no longer active.", - source=self._agent_source(), - ) - timeboxing_activity.mark_active( - user_id=message.user_id, - channel_id=message.channel_id, - thread_ts=message.thread_ts, - ) - self._session_debug( - session, - "stage_action", - action=message.action, - stage_ready=session.stage_ready, - stage=session.stage.value if session.stage else None, - ) - try: - should_force_stage_rerun = False - match message.action: - case "cancel": - session.completed = True - session.thread_state = "canceled" - timeboxing_activity.mark_inactive(user_id=session.user_id) - self._session_debug(session, "session_canceled") - self._close_session_debug_logger(key) - return TextMessage( - content="Okayβ€”stopping this timeboxing session.", - source=self._agent_source(), - ) - case "back": - await self._advance_stage( - session, next_stage=self._previous_stage(session.stage) - ) - should_force_stage_rerun = True - case "proceed": - if not session.stage_ready: - missing_lines = ( - "\n".join( - f"- {item}" for item in (session.stage_missing or []) - ) - if session.stage_missing - else "- (none listed)" - ) - question = ( - session.stage_question - or "Share missing details, then retry." - ) - blocked_reply = TextMessage( - content=( - "Cannot proceed yet.\n" - "Missing:\n" - f"{missing_lines}\n" - f"Question: {question}" - ), - source=self._agent_source(), - ) - outgoing = self._attach_presenter_blocks( - reply=blocked_reply, session=session - ) - await self._publish_update( - session=session, - user_message=( - outgoing.content - if isinstance(outgoing, TextMessage) - else getattr(outgoing, "text", "") - ), - actions=[], - ) - return outgoing - await self._proceed(session) - should_force_stage_rerun = True - case "redo": - undone_reply = await self._undo_last_refine_update(session=session) - if undone_reply is not None: - outgoing = self._attach_presenter_blocks( - reply=undone_reply, session=session - ) - await self._publish_update( - session=session, - user_message=( - outgoing.content - if isinstance(outgoing, TextMessage) - else getattr(outgoing, "text", "") - ), - actions=[], - ) - return outgoing - should_force_stage_rerun = True - case _: - return TextMessage( - content=f"Unknown stage action: {message.action}", - source=self._agent_source(), - ) - - session.thread_state = None - session.force_stage_rerun = should_force_stage_rerun - reply = await self._run_graph_turn(session=session, user_text="") - wrapped = await self._maybe_wrap_constraint_review( - reply=reply, session=session - ) - outgoing = self._attach_presenter_blocks(reply=wrapped, session=session) - await self._publish_update( - session=session, - user_message=( - outgoing.content - if isinstance(outgoing, TextMessage) - else getattr(outgoing, "text", "") - ), - actions=[], - ) - if session.thread_state: - timeboxing_activity.mark_inactive(user_id=session.user_id) - return SlackThreadStateMessage( - text=reply.content, - thread_state=session.thread_state, - ) - return outgoing - except Exception as exc: - self._session_debug( - session, - "stage_action_error", - action=message.action, - error_type=type(exc).__name__, - error=str(exc)[:2000], - ) - raise - - @message_handler - async def on_confirm_submit( - self, message: TimeboxingConfirmSubmit, ctx: MessageContext - ) -> TextMessage | SlackBlockMessage: - """Handle explicit Stage 5 confirm-submit action.""" - key = self._session_key(ctx, fallback=message.thread_ts) - session = self._sessions.get(key) - if not session: - return TextMessage( - content="That timeboxing session is no longer active.", - source=self._agent_source(), - ) - if session.completed or session.thread_state in {"done", "canceled"}: - return TextMessage( - content="This session has already ended; submission is no longer available.", - source=self._agent_source(), - ) - return await self._submit_pending_plan( - session=session, - submit_mode="manual_button", - submit_attempt_kind=self._submit_attempt_kind(session), - ) - - @staticmethod - def _submit_attempt_kind(session: Session) -> Literal["first_submit", "resubmit"]: - """Return submit attempt kind for telemetry and UX clarity.""" - return "resubmit" if session.last_sync_transaction is not None else "first_submit" - - def _build_submit_incomplete_reply(self) -> TextMessage: - """Return deterministic copy when submit is requested before prerequisites exist.""" - return TextMessage( - content="Cannot submit yet because the plan is incomplete. Please refine first.", - source=self._agent_source(), - ) - - async def _submit_pending_plan( - self, - *, - session: Session, - submit_mode: Literal["auto_nl", "manual_button"], - submit_attempt_kind: Literal["first_submit", "resubmit"], - ) -> TextMessage | SlackBlockMessage: - """Submit the pending Stage 5 plan to calendar when ready.""" - if not session.pending_submit: - self._session_debug( - session, - "submission_skipped", - reason="not_pending_submit", - submit_mode=submit_mode, - submit_attempt_kind=submit_attempt_kind, - ) - return TextMessage( - content="There is no pending plan submission right now.", - source=self._agent_source(), - ) - if session.tb_plan is None: - session.pending_submit = False - self._session_debug( - session, - "submission_skipped", - reason="missing_tb_plan", - submit_mode=submit_mode, - submit_attempt_kind=submit_attempt_kind, - ) - return self._build_submit_incomplete_reply() - baseline_guard = await self._ensure_submit_baseline_ready( - session=session, - context="stage5_submit_pending_plan", - ) - if not baseline_guard.ready: - self._session_debug( - session, - "submission_skipped", - reason=baseline_guard.reason, - submit_mode=submit_mode, - submit_attempt_kind=submit_attempt_kind, - ) - if baseline_guard.reason == "remote_baseline_refresh_failed": - return TextMessage( - content=( - "I couldn't refresh the latest calendar state, so I did not submit " - "to avoid duplicate events. Please retry in a moment." - ), - source=self._agent_source(), - ) - session.pending_submit = False - return self._build_submit_incomplete_reply() - - reconciliation_summary = self._build_reconciliation_summary( - session=session, - context="stage5_submit_pending_plan", - ) - preview_ops = [] - if submit_attempt_kind == "resubmit" and reconciliation_summary is None: - preview_ops = plan_sync( - session.base_snapshot, - session.tb_plan, - session.event_id_map, - remote_event_ids_by_index=session.remote_event_ids_by_index, - ) - planned_mutations = ( - reconciliation_summary.planned_mutations - if reconciliation_summary is not None - else len(preview_ops) - ) - if submit_attempt_kind == "resubmit" and planned_mutations == 0: - session.pending_submit = False - session.committed = True - debug_payload = { - "status": "committed", - "changed": False, - "created": 0, - "updated": 0, - "deleted": 0, - "failed": 0, - "failed_ops": [], - "ops": 0, - "elapsed_s": 0.0, - "submit_mode": submit_mode, - "submit_attempt_kind": submit_attempt_kind, - "no_material_delta": True, - } - if reconciliation_summary is not None: - debug_payload.update( - { - "remote_fetched": reconciliation_summary.remote_fetched, - "matched": reconciliation_summary.matched, - "planned_create": reconciliation_summary.create, - "planned_update": reconciliation_summary.update, - "planned_noop": reconciliation_summary.noop, - "planned_delete": reconciliation_summary.delete, - } - ) - self._session_debug( - session, - "submission_result", - **debug_payload, - ) - text = "Calendar already up to date. No changes were needed." - if reconciliation_summary is not None: - text = ( - f"{text}\n\n" - f"{self._format_reconciliation_summary(reconciliation_summary)}" - ) - return SlackBlockMessage( - text=text, - blocks=[build_markdown_block(text=text)], - ) - - submit_started_at = perf_counter() - self._session_debug( - session, - "submission_start", - remote_events=len(session.base_snapshot.events), - event_id_map_size=len(session.event_id_map), - submit_mode=submit_mode, - submit_attempt_kind=submit_attempt_kind, - ) - previous_map = dict(session.event_id_map) - try: - tx = await self._calendar_submitter.submit_plan( - desired=session.tb_plan, - remote=session.base_snapshot, - event_id_map=session.event_id_map, - remote_event_ids_by_index=session.remote_event_ids_by_index, - ) - except Exception as exc: - logger.exception("Calendar submission failed.") - self._session_debug( - session, - "submission_error", - error_type=type(exc).__name__, - error=str(exc)[:2000], - elapsed_s=round(perf_counter() - submit_started_at, 3), - submit_mode=submit_mode, - submit_attempt_kind=submit_attempt_kind, - ) - return TextMessage( - content="Calendar submission failed. Please try again.", - source=self._agent_source(), - ) - - session.pending_submit = False - session.committed = True - session.last_sync_transaction = tx - session.last_sync_event_id_map = previous_map - session.event_id_map = self._update_event_id_map_after_submit( - session=session, - transaction=tx, - ) - await self._refresh_remote_baseline_after_sync(session) - summary = self._summarize_sync_transaction(tx) - debug_payload = { - "status": summary.status, - "changed": summary.changed, - "created": summary.created, - "updated": summary.updated, - "deleted": summary.deleted, - "failed": summary.failed, - "failed_ops": summary.failed_details[:3], - "ops": len(tx.ops), - "elapsed_s": round(perf_counter() - submit_started_at, 3), - "submit_mode": submit_mode, - "submit_attempt_kind": submit_attempt_kind, - "no_material_delta": False, - } - if reconciliation_summary is not None: - debug_payload.update( - { - "remote_fetched": reconciliation_summary.remote_fetched, - "matched": reconciliation_summary.matched, - "planned_create": reconciliation_summary.create, - "planned_update": reconciliation_summary.update, - "planned_noop": reconciliation_summary.noop, - "planned_delete": reconciliation_summary.delete, - } - ) - self._session_debug( - session, - "submission_result", - **debug_payload, - ) - - if tx.status == "committed": - changes = [] - if summary.created > 0: - changes.append(f"{summary.created} new events") - if summary.updated > 0: - changes.append(f"{summary.updated} updated events") - if summary.deleted > 0: - changes.append(f"{summary.deleted} deleted events") - change_str = ", ".join(changes) if changes else "No changes needed" - - text = f"βœ… Submitted to Google Calendar ({change_str}). You can undo this submission if you'd like to revert." - elif tx.status == "partial": - first_error = ( - summary.failed_details[0].get("error", "").strip() - if summary.failed_details - else "" - ) - if first_error: - text = ( - "Submission completed with partial failures. " - f"First error: {first_error}" - ) - else: - text = ( - "Submission completed with partial failures. " - "You can undo attempted changes." - ) - else: - text = f"Submission finished with status `{tx.status}`." - if reconciliation_summary is not None: - text = ( - f"{text}\n\n" - f"{self._format_reconciliation_summary(reconciliation_summary)}" - ) - return SlackBlockMessage( - text=text, - blocks=self._render_submit_result_blocks( - session=session, - text=text, - include_undo=True, - ), - ) - - @message_handler - async def on_cancel_submit( - self, message: TimeboxingCancelSubmit, ctx: MessageContext - ) -> TextMessage: - """Handle Stage 5 cancel-submit action and return to refine stage.""" - key = self._session_key(ctx, fallback=message.thread_ts) - session = self._sessions.get(key) - if not session: - return TextMessage( - content="That timeboxing session is no longer active.", - source=self._agent_source(), - ) - session.pending_submit = False - await self._advance_stage(session, next_stage=TimeboxingStage.REFINE) - return TextMessage( - content=( - "Submission canceled. Returned to Stage 4/5 (Refine). " - "Share what to adjust next." - ), - source=self._agent_source(), - ) - - @message_handler - async def on_undo_submit( - self, message: TimeboxingUndoSubmit, ctx: MessageContext - ) -> TextMessage | SlackBlockMessage: - """Handle Stage 5 undo-submit action using session-backed transaction state.""" - key = self._session_key(ctx, fallback=message.thread_ts) - session = self._sessions.get(key) - if not session: - return TextMessage( - content="That timeboxing session is no longer active.", - source=self._agent_source(), - ) - if session.completed or session.thread_state in {"done", "canceled"}: - return TextMessage( - content="Undo is unavailable because this session has already ended.", - source=self._agent_source(), - ) - transaction = session.last_sync_transaction - if transaction is None: - return TextMessage( - content="There is no submission to undo.", - source=self._agent_source(), - ) - - try: - undo_tx = await self._calendar_submitter.undo_transaction(transaction) - except Exception: - logger.exception("Undo submission failed.") - return TextMessage( - content="Undo failed. Please try again.", - source=self._agent_source(), - ) - if undo_tx is None: - return TextMessage( - content="Undo is not available for the latest transaction.", - source=self._agent_source(), - ) - - session.pending_submit = False - session.last_sync_transaction = None - if session.last_sync_event_id_map is not None: - session.event_id_map = dict(session.last_sync_event_id_map) - session.last_sync_event_id_map = None - - await self._advance_stage(session, next_stage=TimeboxingStage.REFINE) - if session.base_snapshot is not None: - from .timebox import tb_plan_to_timebox - - session.tb_plan = session.base_snapshot.model_copy(deep=True) - try: - session.timebox = tb_plan_to_timebox(session.tb_plan) - except Exception: - logger.debug( - "Failed to convert restored TBPlan to Timebox after undo.", - exc_info=True, - ) - - if undo_tx.status == "undone": - text = "Undo successful. Restored your plan and returned to Refine." - else: - text = ( - f"Undo completed with status `{undo_tx.status}`. " - "Please review the plan in Refine." - ) - return SlackBlockMessage( - text=text, - blocks=self._render_submit_result_blocks( - session=session, - text=text, - include_undo=False, - ), - ) - - @message_handler - async def on_finalise( - self, message: TimeboxingFinalResult, ctx: MessageContext - ) -> TextMessage: - """Handle finalization callbacks and clean up session state.""" - key = self._session_key(ctx) - session = self._sessions.pop(key, None) - if session: - self._session_debug( - session, - "session_finalized", - status=message.status, - summary=message.summary, - ) - if session.session_key and session.session_key != key: - self._close_session_debug_logger(session.session_key) - self._close_session_debug_logger(key) - return TextMessage( - content=f"Session {message.thread_ts} marked {message.status}: {message.summary}", - source=self.id.type, - ) - - async def cleanup(self) -> None: - """Cleanup resources before shutdown.""" - for session_key in list(self._session_debug_loggers.keys()): - self._close_session_debug_logger(session_key) - if self._calendar_client: - await self._calendar_client.close() - if self._constraint_memory_client: - close = getattr(self._constraint_memory_client, "close", None) - if callable(close): - await close() - - -def _extract_constraint_batch(response: object) -> ConstraintBatch | None: - """Extract a constraint batch from an agent response.""" - content = getattr(getattr(response, "chat_message", None), "content", None) - if isinstance(content, ConstraintBatch): - return content - if content is not None: - try: - return ConstraintBatch.model_validate(content) - except Exception: - return None - return None - - -# TODO: remove this hacky bullshit -def _capture_from_content(content) -> Timebox | None: - """Parse a Timebox instance from arbitrary content payloads.""" - if isinstance(content, Timebox): - return content - if isinstance(content, dict): - try: - return Timebox.model_validate(content) - except Exception: - return None - return None - - -# TODO: remove this hacky bullshit -def _capture_timebox(session: Session, content) -> None: - """Capture a timebox into the session when present.""" - timebox = _capture_from_content(content) - if timebox: - session.timebox = timebox - - -def _build_actions( - before: Timebox, - after: Timebox, - *, - reason: str, - constraints: List[Constraint], -) -> List[TimeboxAction]: - """Compute timebox change actions for downstream logging.""" - actions: List[TimeboxAction] = [] - before_map = _event_map(before.events) - after_map = _event_map(after.events) - - for key, event in after_map.items(): - if key not in before_map: - actions.append( - TimeboxAction( - kind="insert", - event_key=key, - summary=event.summary, - to_time=_format_time(event.start_time), - reason=_build_reason(reason, constraints), - ) - ) - - for key, event in before_map.items(): - if key not in after_map: - actions.append( - TimeboxAction( - kind="delete", - event_key=key, - summary=event.summary, - from_time=_format_time(event.start_time), - reason=_build_reason(reason, constraints), - ) - ) - - for key, event in after_map.items(): - if key not in before_map: - continue - before_event = before_map[key] - if ( - before_event.start_time != event.start_time - or before_event.end_time != event.end_time - ): - actions.append( - TimeboxAction( - kind="move", - event_key=key, - summary=event.summary, - from_time=_format_time(before_event.start_time), - to_time=_format_time(event.start_time), - reason=_build_reason(reason, constraints), - ) - ) - elif ( - before_event.summary != event.summary - or before_event.description != event.description - or before_event.location != event.location - ): - actions.append( - TimeboxAction( - kind="update", - event_key=key, - summary=event.summary, - reason=_build_reason(reason, constraints), - ) - ) - - return actions - - -def _build_reason(user_message: str, constraints: List[Constraint]) -> str: - """Build a human-readable reason string for action logs.""" - names = [c.name for c in constraints if c.name] - if names: - return f"user: {user_message} | constraints: {', '.join(names)}" - return f"user: {user_message}" - - -def _event_map(events: List[object]) -> Dict[str, object]: - """Return a stable mapping of events keyed by identifiers.""" - mapping: Dict[str, object] = {} - for idx, event in enumerate(events): - key = _event_key(event, idx) - mapping[key] = event - return mapping - - -def _event_key(event: object, idx: int) -> str: - """Return a stable identifier for a timebox event.""" - event_id = getattr(event, "eventId", None) - if event_id: - return f"id:{event_id}" - summary = getattr(event, "summary", None) or "event" - start = getattr(event, "start_time", None) - end = getattr(event, "end_time", None) - if start or end: - return f"{summary}:{start}:{end}" - return f"{summary}:{idx}" - - -def _format_time(value) -> str | None: - """Format a time value as HH:MM when present.""" - if value is None: - return None - return value.strftime("%H:%M") - - -def _constraint_necessity_rank() -> dict[ConstraintNecessity | str, int]: - """Return a stable necessity rank map tolerant to older enum definitions.""" - rank: dict[ConstraintNecessity | str, int] = { - ConstraintNecessity.MUST: 0, - ConstraintNecessity.SHOULD: 1, - } - prefer = getattr(ConstraintNecessity, "PREFER", None) - if prefer is not None: - rank[prefer] = 2 - rank["prefer"] = 2 - return rank - - -def _constraint_scope_rank(scope: ConstraintScope | str | None) -> int: - """Rank scope precedence for reconciliation, preferring near-session intent.""" - if scope == ConstraintScope.SESSION: - return 0 - if scope == ConstraintScope.DATESPAN: - return 1 - if scope == ConstraintScope.PROFILE: - return 2 - return 3 - - -def _constraint_name_signature(name: str) -> str: - """Build a stable normalized signature for family-level reconciliation.""" - text = str(name or "").strip().lower() - if not text: - return "" - normalized = re.sub(r"[^a-z0-9]+", " ", text) - tokens = [token for token in normalized.split() if token] - if not tokens: - return "" - informative_tokens = [t for t in tokens if not re.fullmatch(r"v?\d+", t)] - if informative_tokens: - tokens = informative_tokens - stopwords = { - "the", - "a", - "an", - "and", - "or", - "for", - "to", - "of", - "in", - "on", - "at", - "with", - "this", - "that", - "always", - "should", - } - filtered = [token for token in tokens if token not in stopwords] - if not filtered: - filtered = tokens - return " ".join(filtered) - - -def _constraint_priority(constraint: Constraint) -> tuple[int, int, str]: - """Rank constraints so the top rows are the most decision-critical.""" - necessity_rank = _constraint_necessity_rank() - status_rank = { - ConstraintStatus.LOCKED: 0, - ConstraintStatus.PROPOSED: 1, - ConstraintStatus.DECLINED: 2, - } - necessity_value: ConstraintNecessity | str = constraint.necessity - if necessity_value not in necessity_rank and necessity_value is not None: - necessity_value = str(necessity_value).lower() - return ( - necessity_rank.get(necessity_value, 3), - status_rank.get(constraint.status, 3), - (constraint.name or "").lower(), - ) - - -def _wrap_with_constraint_review( - message: TextMessage, - *, - constraints: list[Constraint], - session: Session, -) -> SlackBlockMessage: - """Attach a compact constraint-review section to a stage response.""" - blocks: list[dict[str, Any]] = [build_markdown_block(text=message.content)] - if constraints: - ranked = sorted(constraints, key=_constraint_priority) - summary_line = _constraint_count_summary_line( - session=session, - review_count=len(ranked), - include_newly_extracted_label="Newly extracted this turn", - ) - blocks.append({"type": "divider"}) - blocks.append( - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ( - f"*Constraints*\n" - f"{summary_line} " - f"Showing the top {min(3, len(ranked))} of {len(ranked)}. " - "Use Deny / Edit or open the full list." - ), - }, - } - ) - blocks.extend( - build_constraint_row_blocks( - ranked, - thread_ts=session.thread_ts, - user_id=session.user_id, - limit=3, - button_text="Deny / Edit", - ) - ) - if len(ranked) > 3: - blocks.append( - build_constraint_review_all_action_block( - thread_ts=session.thread_ts, - user_id=session.user_id, - count=len(ranked), - ) - ) - return SlackBlockMessage(text=message.content, blocks=blocks) - - -# TODO(refactor): Replace manual enum coercion with Pydantic model validation. -def _parse_enum(enum_cls: Type[TEnum], value: object, default: TEnum) -> TEnum: - """Coerce a value into the requested Enum, or return a default.""" - if isinstance(value, enum_cls): - return value - if value is None: - return default - normalized = str(value).strip() - if not normalized: - return default - adapter = TypeAdapter(enum_cls) - candidates = [normalized, normalized.lower(), normalized.upper()] - for candidate in candidates: - try: - return adapter.validate_python(candidate) - except ValidationError: - continue - return default - - -# TODO(refactor): Parse enums/dates via Pydantic fields instead of try/except. -@_fallback_on_parse_error(default=None) -def _parse_dow(value: str | None) -> ConstraintDayOfWeek | None: - """Parse a day-of-week enum from a string.""" - if not value: - return None - return TypeAdapter(ConstraintDayOfWeek).validate_python(str(value).upper()) - - -# TODO(refactor): Parse ISO dates via Pydantic fields instead of try/except. -@_fallback_on_parse_error(default=None) -def _parse_date_value(value: str | None) -> date | None: - """Parse an ISO date string into a date.""" - if not value: - return None - return TypeAdapter(date).validate_python(value) - - -# TODO: this should be part of a tool, not bolted onto an agent -def _constraints_from_memory( - records: list[dict[str, Any]], *, user_id: str -) -> list[Constraint]: - """Convert constraint-memory records to local Constraint instances.""" - constraints: list[Constraint] = [] - for record in records: - if not isinstance(record, dict): - continue - necessity = _parse_enum( - ConstraintNecessity, record.get("necessity"), ConstraintNecessity.SHOULD - ) - status = _parse_enum( - ConstraintStatus, record.get("status"), ConstraintStatus.PROPOSED - ) - source = _parse_enum( - ConstraintSource, record.get("source"), ConstraintSource.SYSTEM - ) - scope = _parse_enum( - ConstraintScope, record.get("scope"), ConstraintScope.PROFILE - ) - days_raw = record.get("days_of_week") or [] - days = [d for d in (_parse_dow(v) for v in days_raw) if d] - hints = {} - uid = record.get("uid") - if uid: - hints["uid"] = uid - rule_kind = record.get("rule_kind") or record.get("type_id") - if rule_kind: - hints["rule_kind"] = rule_kind - applies_stages = record.get("applies_stages") - if isinstance(applies_stages, list) and applies_stages: - hints["applies_stages"] = [str(value) for value in applies_stages] - # Restore aspect_classification from the stored record so agent code can read - # hints["aspect_classification"] without keyword or regex scanning. - aspect_cls = record.get("aspect_classification") - if isinstance(aspect_cls, dict) and aspect_cls: - raw_slot = aspect_cls.get("frame_slot") - if raw_slot is not None: - aspect_cls = dict(aspect_cls) - aspect_cls["frame_slot"] = _normalise_frame_slot(raw_slot) - hints["aspect_classification"] = aspect_cls - confidence = record.get("confidence") - parsed_confidence: float | None = None - if confidence is not None: - try: - parsed_confidence = float(confidence) - if parsed_confidence < 0.7: - hints["needs_confirmation"] = True - except (TypeError, ValueError): - parsed_confidence = None - constraints.append( - Constraint( - user_id=user_id, - channel_id=None, - thread_ts=None, - name=record.get("name") or "Constraint", - description=record.get("description") or "", - necessity=necessity, - status=status, - source=source, - scope=scope, - tags=list(record.get("topics") or []), - hints=hints, - confidence=parsed_confidence, - start_date=_parse_date_value(record.get("start_date")), - end_date=_parse_date_value(record.get("end_date")), - days_of_week=days, - timezone=record.get("timezone"), - ) - ) - return constraints - - -def _constraint_identity_key(constraint: ConstraintBase) -> str: - """Build a stable identity key for a constraint to support dedupe.""" - hints = constraint.hints if isinstance(constraint.hints, dict) else {} - uid = hints.get("uid") - if uid: - return f"uid:{uid}" - days = ",".join(sorted(str(day) for day in list(constraint.days_of_week or []))) - tags = ",".join(sorted(str(tag).strip().lower() for tag in (constraint.tags or []))) - rule_kind = str(hints.get("rule_kind") or "").strip().lower() - scope = constraint.scope.value if constraint.scope else "" - normalized_desc = " ".join((constraint.description or "").strip().lower().split()) - return "|".join( - [ - scope, - rule_kind, - (constraint.name or "").strip().lower(), - normalized_desc, - str(constraint.start_date or ""), - str(constraint.end_date or ""), - days, - str(constraint.timezone or ""), - tags, - ] - ) - - -def _constraint_canonical_rank(constraint: Constraint) -> tuple[int, int, float, str]: - """Rank duplicates so canonical rows prefer strong/active/recent constraints.""" - status_rank = { - ConstraintStatus.LOCKED: 0, - ConstraintStatus.PROPOSED: 1, - ConstraintStatus.DECLINED: 2, - } - necessity_rank = _constraint_necessity_rank() - necessity_value: ConstraintNecessity | str = constraint.necessity - if necessity_value not in necessity_rank and necessity_value is not None: - necessity_value = str(necessity_value).lower() - updated = getattr(constraint, "updated_at", None) - updated_epoch = ( - updated.timestamp() - if isinstance(updated, datetime) - else 0.0 - ) - return ( - status_rank.get(constraint.status, 3), - necessity_rank.get(necessity_value, 3), - -updated_epoch, - (constraint.name or "").strip().lower(), - ) - - -def _dedupe_constraints(constraints: list[Constraint]) -> list[Constraint]: - """Return a de-duplicated list with canonical precedence for duplicates.""" - grouped: dict[str, list[Constraint]] = {} - for constraint in constraints: - key = _constraint_identity_key(constraint) - grouped.setdefault(key, []).append(constraint) - deduped: list[Constraint] = [] - for entries in grouped.values(): - ranked = sorted(entries, key=_constraint_canonical_rank) - deduped.append(ranked[0]) - return deduped - - -def _constraint_applies_to_stage( - constraint: Constraint, - *, - stage: TimeboxingStage, -) -> bool: - """Return whether a constraint is applicable for the current stage.""" - def _as_list(value: object) -> list[object]: - if isinstance(value, list): - return value - if value in (None, ""): - return [] - return [value] - - hints = constraint.hints if isinstance(constraint.hints, dict) else {} - selector = constraint.selector if isinstance(constraint.selector, dict) else {} - raw_stages = _as_list(hints.get("applies_stages")) + _as_list( - selector.get("applies_stages") - ) - if not raw_stages: - return True - normalized = {str(value).strip().lower() for value in raw_stages if str(value).strip()} - return stage.value.strip().lower() in normalized - - -def _constraint_applies_to_planned_date( - constraint: Constraint, - *, - planned_date: str | None, -) -> bool: - """Return whether a constraint is active for the planned date window/day.""" - planned = _parse_date_value(planned_date) - if planned is None: - return True - start_date = ( - constraint.start_date - if isinstance(constraint.start_date, date) - else _parse_date_value(str(constraint.start_date or "")) - ) - end_date = ( - constraint.end_date - if isinstance(constraint.end_date, date) - else _parse_date_value(str(constraint.end_date or "")) - ) - if start_date and planned < start_date: - return False - if end_date and planned > end_date: - return False - if constraint.days_of_week: - planned_dow = ConstraintDayOfWeek(planned.strftime("%a")[:2].upper()) - return planned_dow in set(constraint.days_of_week or []) - return True - - -def _constraint_is_applicable_for_session( - constraint: Constraint, - *, - planned_date: str | None, - stage: TimeboxingStage, -) -> bool: - """Return whether a constraint should count as active for this session turn.""" - return _constraint_applies_to_planned_date( - constraint, planned_date=planned_date - ) and _constraint_applies_to_stage(constraint, stage=stage) - - -def _constraint_count_summary_line( - *, - session: Session, - review_count: int, - include_newly_extracted_label: str, -) -> str: - """Build explicit count wording for constraint preview/review blocks.""" - extracted = max(0, int(session.last_extracted_constraints_count or 0)) - applicable_total = max( - 0, - int( - session.active_constraints_applicable_count - if session.active_constraints_applicable_count - else len(session.active_constraints or []) - ), - ) - selected_total = max( - 0, - int( - session.active_constraints_selected_count - if session.active_constraints_selected_count - else len(session.active_constraints or []) - ), - ) - raw_total = max(0, int(session.active_constraints_raw_count or applicable_total)) - parts = [ - f"{include_newly_extracted_label}: {extracted}.", - f"Active total (applicable now): {applicable_total}.", - ] - if raw_total > applicable_total: - parts.append(f"Raw active rows before filtering: {raw_total}.") - if session.stage == TimeboxingStage.REFINE: - selected_for_refine = max( - 0, int(session.last_refine_selected_constraints_count or 0) - ) - dropped_for_refine = max( - 0, int(session.last_refine_dropped_constraints_count or 0) - ) - line = f"Selected for Refine patching: {selected_for_refine}." - if dropped_for_refine: - # Said out loud, in Slack. Ranking by necessity means the rules - # left out are the softer ones -- exactly the preferences a user - # is most likely to notice missing and least likely to guess the - # reason for. - line += ( - f" {dropped_for_refine} lower-priority constraint" - f"{'s' if dropped_for_refine != 1 else ''} did not fit this pass." - ) - parts.append(line) - elif 0 < selected_total < applicable_total: - parts.append(f"Selected for this stage: {selected_total}.") - return " ".join(parts) - - -_FRAME_SLOT_ALIASES: dict[str, str] = { - "evening_ritual": "evening_wind_down", - "evening_routine": "evening_wind_down", - "wind_down": "evening_wind_down", - "pre_sleep_prep": "shutdown", - "shutdown_ritual": "shutdown", - "bedtime_prep": "shutdown", - "pre_gym": "pre_gym_meal", - "pre_gym_oats": "pre_gym_meal", - "morning_routine": "morning_ritual", - "morning": "morning_ritual", - "commute": "commute_out", - "commute_to_work": "commute_out", - "commute_home": "commute_back", - "lunch": "lunch_break", - "sleep": "sleep_target", - "bed": "sleep_target", - "music": "music_making", - "dog": "dog_walk", - "walk_dog": "dog_walk", -} - - -AUTO_PROMOTE_THRESHOLD: int = 3 - - -def _increment_session_appearances(lifecycle: dict[str, Any]) -> dict[str, Any]: - """Return a new lifecycle dict with session_appearances incremented by 1.""" - updated = dict(lifecycle) - updated["session_appearances"] = int(lifecycle.get("session_appearances") or 0) + 1 - return updated - - -def _should_auto_promote(*, session_appearances: int, necessity: str) -> bool: - """Return True when a MUST-level constraint has been seen enough times to promote.""" - return necessity == "MUST" and session_appearances >= AUTO_PROMOTE_THRESHOLD - - -def _normalise_frame_slot(slot: str | None) -> str | None: - """Normalise a frame_slot slug to its canonical form, returning as-is if novel.""" - if not slot: - return None - normalised = str(slot).strip().lower() - if not normalised: - return None - return _FRAME_SLOT_ALIASES.get(normalised, normalised) - - -# TODO: this should not be neccesary at all -def _coerce_async_database_url(database_url: str) -> str: - """Ensure a database URL uses an async driver when needed.""" - if database_url.startswith("sqlite+aiosqlite://"): - return database_url - if database_url.startswith("sqlite://"): - return database_url.replace("sqlite://", "sqlite+aiosqlite://", 1) - return database_url - - -__all__ = ["TimeboxingFlowAgent"] -__all__ = ["TimeboxingFlowAgent"] diff --git a/src/fateforger/agents/timeboxing/calendar_reconciliation.py b/src/fateforger/agents/timeboxing/calendar_reconciliation.py deleted file mode 100644 index 53143aeb..00000000 --- a/src/fateforger/agents/timeboxing/calendar_reconciliation.py +++ /dev/null @@ -1,394 +0,0 @@ -"""Reconciliation helpers for desired-vs-remote calendar planning. - -This module matches desired TBPlan events to remote TBPlan events using -deterministic pass ordering so sync can emit stable calendar operations -without duplicate creates. -""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import time -from typing import Any, Literal - -from .tb_models import TBPlan - -MatchKind = Literal["id", "canonical", "fuzzy"] - - -def event_key(summary: str, start_time: time) -> str: - """Return the canonical key used by legacy event-id maps.""" - return f"{summary}|{start_time.isoformat()}" - - -def canonical_tuple(summary: str, start_time: time, end_time: time) -> tuple[str, str, str]: - """Return a hashable canonical identity tuple.""" - return (summary, start_time.isoformat(), end_time.isoformat()) - - -def _normalize_summary(summary: str) -> str: - return " ".join(summary.strip().lower().split()) - - -def _minutes_between(a: time, b: time) -> int: - return abs((a.hour * 60 + a.minute) - (b.hour * 60 + b.minute)) - - -def _overlap_minutes(a_start: time, a_end: time, b_start: time, b_end: time) -> int: - start = max(a_start.hour * 60 + a_start.minute, b_start.hour * 60 + b_start.minute) - end = min(a_end.hour * 60 + a_end.minute, b_end.hour * 60 + b_end.minute) - return max(0, end - start) - - -def _duration_minutes(start: time, end: time) -> int: - start_min = start.hour * 60 + start.minute - end_min = end.hour * 60 + end.minute - return max(0, end_min - start_min) - - -@dataclass(frozen=True) -class RemoteEventRecord: - """Resolved remote event enriched with identity metadata.""" - - index: int - summary: str - start_time: time - end_time: time - event_id: str | None - is_owned: bool - resolved: dict[str, Any] - - @property - def key(self) -> str: - return event_key(self.summary, self.start_time) - - @property - def canonical(self) -> tuple[str, str, str]: - return canonical_tuple(self.summary, self.start_time, self.end_time) - - -@dataclass(frozen=True) -class DesiredEventRecord: - """Resolved desired event enriched with prior lineage hints.""" - - index: int - summary: str - start_time: time - end_time: time - hinted_event_id: str | None - resolved: dict[str, Any] - - @property - def key(self) -> str: - return event_key(self.summary, self.start_time) - - @property - def canonical(self) -> tuple[str, str, str]: - return canonical_tuple(self.summary, self.start_time, self.end_time) - - -@dataclass(frozen=True) -class EventMatch: - """A one-to-one match between desired and remote event records.""" - - desired: DesiredEventRecord - remote: RemoteEventRecord - match_kind: MatchKind - - -@dataclass(frozen=True) -class SkippedItem: - """An explicitly skipped operation candidate and the reason.""" - - reason: str - desired_index: int | None = None - remote_index: int | None = None - - -@dataclass -class CalendarOpPlan: - """Reconciliation output used by the sync engine.""" - - matches: list[EventMatch] = field(default_factory=list) - creates: list[DesiredEventRecord] = field(default_factory=list) - updates: list[EventMatch] = field(default_factory=list) - deletes: list[RemoteEventRecord] = field(default_factory=list) - noops: list[EventMatch] = field(default_factory=list) - skips: list[SkippedItem] = field(default_factory=list) - - -def build_remote_records( - *, - remote: TBPlan, - event_id_map: dict[str, str], - remote_event_ids_by_index: list[str] | None = None, - owned_prefix: str = "fftb", -) -> list[RemoteEventRecord]: - """Build resolved remote records with robust identity lookup.""" - # Remote snapshots can contain historical overlaps; reconciliation should - # stay robust and avoid crashing on those records. - resolved = remote.resolve_times(validate_non_overlap=False) - records: list[RemoteEventRecord] = [] - for index, item in enumerate(resolved): - summary = str(item["n"]) - start_time = item["start_time"] - end_time = item["end_time"] - key = event_key(summary, start_time) - event_id: str | None = None - if remote_event_ids_by_index and index < len(remote_event_ids_by_index): - candidate = remote_event_ids_by_index[index] - event_id = candidate if candidate else None - if event_id is None: - event_id = event_id_map.get(key) - records.append( - RemoteEventRecord( - index=index, - summary=summary, - start_time=start_time, - end_time=end_time, - event_id=event_id, - is_owned=bool(event_id and event_id.startswith(owned_prefix)), - resolved=item, - ) - ) - return records - - -def build_desired_records( - *, - desired: TBPlan, - event_id_map: dict[str, str], -) -> list[DesiredEventRecord]: - """Build resolved desired records with lineage hints from event-id map.""" - resolved = desired.resolve_times() - return [ - DesiredEventRecord( - index=index, - summary=str(item["n"]), - start_time=item["start_time"], - end_time=item["end_time"], - hinted_event_id=event_id_map.get(event_key(str(item["n"]), item["start_time"])), - resolved=item, - ) - for index, item in enumerate(resolved) - ] - - -def reconcile_calendar_ops( - *, - remote: TBPlan, - desired: TBPlan, - event_id_map: dict[str, str], - remote_event_ids_by_index: list[str] | None = None, - fuzzy_start_tolerance_min: int = 20, - foreign_overlap_match_min_percent: int = 80, -) -> CalendarOpPlan: - """Reconcile desired and remote plans into deterministic op candidates.""" - remote_records = build_remote_records( - remote=remote, - event_id_map=event_id_map, - remote_event_ids_by_index=remote_event_ids_by_index, - ) - desired_records = build_desired_records(desired=desired, event_id_map=event_id_map) - - remaining_remote: set[int] = {record.index for record in remote_records} - remaining_desired: set[int] = {record.index for record in desired_records} - remote_by_index = {record.index: record for record in remote_records} - desired_by_index = {record.index: record for record in desired_records} - - matches: list[EventMatch] = [] - - # Pass 1: explicit ID lineage. - remote_by_id: dict[str, list[RemoteEventRecord]] = {} - for record in remote_records: - if record.event_id: - remote_by_id.setdefault(record.event_id, []).append(record) - for desired_record in desired_records: - if desired_record.index not in remaining_desired: - continue - if not desired_record.hinted_event_id: - continue - candidates = [ - record - for record in remote_by_id.get(desired_record.hinted_event_id, []) - if record.index in remaining_remote - ] - if not candidates: - continue - remote_record = min(candidates, key=lambda record: record.index) - matches.append( - EventMatch( - desired=desired_record, - remote=remote_record, - match_kind="id", - ) - ) - remaining_desired.discard(desired_record.index) - remaining_remote.discard(remote_record.index) - - # Pass 2: exact canonical identity. - remote_by_canonical: dict[tuple[str, str, str], list[RemoteEventRecord]] = {} - for index in sorted(remaining_remote): - record = remote_by_index[index] - remote_by_canonical.setdefault(record.canonical, []).append(record) - for desired_index in sorted(remaining_desired): - desired_record = desired_by_index[desired_index] - candidates = remote_by_canonical.get(desired_record.canonical, []) - candidates = [record for record in candidates if record.index in remaining_remote] - if not candidates: - continue - remote_record = min(candidates, key=lambda record: record.index) - matches.append( - EventMatch( - desired=desired_record, - remote=remote_record, - match_kind="canonical", - ) - ) - remaining_desired.discard(desired_record.index) - remaining_remote.discard(remote_record.index) - - # Pass 3: conservative fuzzy match. - for desired_index in sorted(remaining_desired): - desired_record = desired_by_index[desired_index] - desired_summary = _normalize_summary(desired_record.summary) - best_score: tuple[int, int, int] | None = None - best_remote: RemoteEventRecord | None = None - for remote_index in sorted(remaining_remote): - remote_record = remote_by_index[remote_index] - if _normalize_summary(remote_record.summary) != desired_summary: - continue - overlap = _overlap_minutes( - desired_record.start_time, - desired_record.end_time, - remote_record.start_time, - remote_record.end_time, - ) - start_delta = _minutes_between(desired_record.start_time, remote_record.start_time) - if overlap <= 0 and start_delta > fuzzy_start_tolerance_min: - continue - duration_delta = abs( - _minutes_between(desired_record.start_time, desired_record.end_time) - - _minutes_between(remote_record.start_time, remote_record.end_time) - ) - score = (overlap, -start_delta, -duration_delta) - if best_score is None or score > best_score: - best_score = score - best_remote = remote_record - if best_remote is None: - continue - matches.append( - EventMatch( - desired=desired_record, - remote=best_remote, - match_kind="fuzzy", - ) - ) - remaining_desired.discard(desired_record.index) - remaining_remote.discard(best_remote.index) - - # Pass 4: overlap guard against foreign immovables. - # If a desired event almost fully overlaps a foreign remote event, treat it as - # a no-op match to avoid creating duplicate calendar blocks (e.g., seeded lunch). - for desired_index in sorted(remaining_desired): - desired_record = desired_by_index[desired_index] - desired_duration = _duration_minutes( - desired_record.start_time, desired_record.end_time - ) - if desired_duration <= 0: - continue - best_score: tuple[int, int, int, int] | None = None - best_remote: RemoteEventRecord | None = None - for remote_index in sorted(remaining_remote): - remote_record = remote_by_index[remote_index] - if remote_record.is_owned: - continue - remote_duration = _duration_minutes( - remote_record.start_time, remote_record.end_time - ) - if remote_duration <= 0: - continue - overlap = _overlap_minutes( - desired_record.start_time, - desired_record.end_time, - remote_record.start_time, - remote_record.end_time, - ) - if overlap <= 0: - continue - overlap_percent = int((overlap * 100) / min(desired_duration, remote_duration)) - if overlap_percent < foreign_overlap_match_min_percent: - continue - start_delta = _minutes_between( - desired_record.start_time, remote_record.start_time - ) - end_delta = _minutes_between(desired_record.end_time, remote_record.end_time) - score = (overlap_percent, overlap, -start_delta, -end_delta) - if best_score is None or score > best_score: - best_score = score - best_remote = remote_record - if best_remote is None: - continue - matches.append( - EventMatch( - desired=desired_record, - remote=best_remote, - match_kind="fuzzy", - ) - ) - remaining_desired.discard(desired_record.index) - remaining_remote.discard(best_remote.index) - - creates = [desired_by_index[index] for index in sorted(remaining_desired)] - updates: list[EventMatch] = [] - noops: list[EventMatch] = [] - skips: list[SkippedItem] = [] - for match in matches: - if not match.remote.event_id: - skips.append( - SkippedItem( - reason="matched-remote-without-event-id", - desired_index=match.desired.index, - remote_index=match.remote.index, - ) - ) - continue - if match.remote.is_owned: - updates.append(match) - continue - noops.append(match) - - deletes: list[RemoteEventRecord] = [] - for remote_index in sorted(remaining_remote): - remote_record = remote_by_index[remote_index] - if remote_record.is_owned and remote_record.event_id: - deletes.append(remote_record) - continue - skips.append( - SkippedItem( - reason="unmatched-foreign-remote", - remote_index=remote_record.index, - ) - ) - - return CalendarOpPlan( - matches=matches, - creates=creates, - updates=updates, - deletes=deletes, - noops=noops, - skips=skips, - ) - - -__all__ = [ - "CalendarOpPlan", - "DesiredEventRecord", - "EventMatch", - "RemoteEventRecord", - "SkippedItem", - "build_desired_records", - "build_remote_records", - "event_key", - "reconcile_calendar_ops", -] diff --git a/src/fateforger/agents/timeboxing/constants.py b/src/fateforger/agents/timeboxing/constants.py deleted file mode 100644 index a973511a..00000000 --- a/src/fateforger/agents/timeboxing/constants.py +++ /dev/null @@ -1,66 +0,0 @@ -"""Constants and validated defaults for the timeboxing workflow. - -This module exists to keep `agent.py` focused on orchestration logic by extracting: -- timeouts (LLM calls, background IO, Slack UX gates) -- concurrency limits (background tasks / semaphores) -- small deterministic defaults (fallback skeleton settings) - -These values are *not* user configuration. User configuration lives in `fateforger.core.config`. -""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass(frozen=True, slots=True) -class TimeboxingTimeouts: - """Timeout configuration for timeboxing orchestration.""" - - stage_gate_s: float = 90.0 - stage_decision_s: float = 20.0 - constraint_intent_s: float = 10.0 - constraint_interpret_s: float = 20.0 - constraint_extract_s: float = 25.0 - planning_date_interpret_s: float = 10.0 - skeleton_draft_s: float = 120.0 - summary_s: float = 120.0 - review_commit_s: float = 120.0 - notion_extract_s: float = 25.0 - notion_upsert_s: float = 20.0 - calendar_prefetch_wait_s: float = 2.0 - pending_constraints_wait_s: float = 2.0 - durable_prefetch_wait_s: float = 20.0 - tasks_snapshot_s: float = 12.0 - graph_turn_s: float = 120.0 - slow_turn_warn_s: float = 30.0 - refine_summary_min_budget_s: float = 25.0 - refine_quality_min_budget_s: float = 45.0 - - -@dataclass(frozen=True, slots=True) -class TimeboxingLimits: - """Concurrency / size limits for background work.""" - - durable_upsert_concurrency: int = 1 - durable_prefetch_concurrency: int = 3 - constraint_extract_concurrency: int = 2 - - durable_task_key_len: int = 16 - durable_task_queue_limit: int = 10 - - durable_constraint_type_ids_limit: int = 12 - durable_constraint_query_limit: int = 50 - refine_patcher_constraint_limit: int = 24 - - -@dataclass(frozen=True, slots=True) -class FallbackSkeletonDefaults: - """Defaults used when skeleton drafting fails or times out.""" - - focus_block_minutes: int = 90 - - -TIMEBOXING_TIMEOUTS = TimeboxingTimeouts() -TIMEBOXING_LIMITS = TimeboxingLimits() -TIMEBOXING_FALLBACK = FallbackSkeletonDefaults() diff --git a/src/fateforger/agents/timeboxing/constraint_memory_component.py b/src/fateforger/agents/timeboxing/constraint_memory_component.py deleted file mode 100644 index 39540690..00000000 --- a/src/fateforger/agents/timeboxing/constraint_memory_component.py +++ /dev/null @@ -1,169 +0,0 @@ -"""AutoGen Memory component for durable timeboxing constraints. - -This component enriches model context with relevant durable constraints derived -from planning state (stage/date/event types) before tool/LLM reasoning. -""" - -from __future__ import annotations - -import json -from datetime import datetime, timezone -from typing import Any, Callable - -from autogen_core import CancellationToken -from autogen_core.memory import Memory, MemoryContent, MemoryQueryResult, UpdateContextResult -from autogen_core.model_context import ChatCompletionContext -from autogen_core.models import SystemMessage -from pydantic import ValidationError - -from .durable_constraint_store import DurableConstraintStore - - -class ConstraintPlanningMemory(Memory): - """Inject durable constraints into model context using planning-state filters.""" - - component_type = "memory" - - def __init__( - self, - *, - store_provider: Callable[[], DurableConstraintStore | None], - max_items: int = 12, - ) -> None: - self._store_provider = store_provider - self._max_items = max(1, int(max_items)) - self._planning_state: dict[str, Any] = {} - - def set_planning_state(self, state: dict[str, Any] | None) -> None: - """Update the planning state used for next retrieval.""" - self._planning_state = dict(state or {}) - - async def update_context( - self, - model_context: ChatCompletionContext, - ) -> UpdateContextResult: - store = self._store_provider() - if store is None: - return UpdateContextResult(memories=MemoryQueryResult(results=[])) - - today = datetime.now(timezone.utc).date().isoformat() - stage = str(self._planning_state.get("stage") or "").strip() or None - event_types_raw = self._planning_state.get("event_types") or [] - event_types = [str(item).strip() for item in event_types_raw if str(item).strip()] - as_of = str(self._planning_state.get("planned_date") or today).strip() or today - - filters: dict[str, Any] = { - "as_of": as_of, - "require_active": True, - "statuses_any": ["locked", "proposed"], - } - if stage: - filters["stage"] = stage - if event_types: - filters["event_types_any"] = event_types - - rows = await store.query_constraints( - filters=filters, - type_ids=None, - tags=None, - sort=[["Status", "descending"], ["Name", "ascending"]], - limit=self._max_items, - ) - memories: list[MemoryContent] = [] - for row in rows: - if not isinstance(row, dict): - continue - memories.append( - MemoryContent( - content=json.dumps(row, ensure_ascii=False), - mime_type="application/json", - metadata={"uid": row.get("uid"), "kind": "timeboxing_constraint"}, - ) - ) - if memories: - lines: list[str] = [] - for idx, row in enumerate(rows[: self._max_items], start=1): - name = str(row.get("name") or "Constraint").strip() - description = str(row.get("description") or "").strip() - if description: - lines.append(f"{idx}. {name}: {description}") - else: - lines.append(f"{idx}. {name}") - if lines: - await model_context.add_message( - SystemMessage( - content=( - "Relevant durable constraints for this stage:\n" - + "\n".join(lines) - ) - ) - ) - return UpdateContextResult(memories=MemoryQueryResult(results=memories)) - - async def query( - self, - query: str | MemoryContent, - cancellation_token: CancellationToken | None = None, - **kwargs: Any, - ) -> MemoryQueryResult: - _ = cancellation_token, kwargs - store = self._store_provider() - if store is None: - return MemoryQueryResult(results=[]) - text_query = query if isinstance(query, str) else str(query.content) - rows = await store.query_constraints( - filters={"text_query": text_query, "require_active": False}, - type_ids=None, - tags=None, - sort=[["Status", "descending"]], - limit=self._max_items, - ) - return MemoryQueryResult( - results=[ - MemoryContent( - content=json.dumps(row, ensure_ascii=False), - mime_type="application/json", - metadata={"uid": row.get("uid"), "kind": "timeboxing_constraint"}, - ) - for row in rows - if isinstance(row, dict) - ] - ) - - async def add( - self, content: MemoryContent, cancellation_token: CancellationToken | None = None - ) -> None: - _ = cancellation_token - store = self._store_provider() - if store is None: - return - if content.mime_type == "application/json": - try: - parsed = ( - content.content - if isinstance(content.content, dict) - else json.loads(str(content.content)) - ) - except (TypeError, json.JSONDecodeError, ValidationError, ValueError): - parsed = None - if isinstance(parsed, dict) and ( - "constraint_record" in parsed or "name" in parsed - ): - record = ( - parsed - if "constraint_record" in parsed - else {"constraint_record": parsed} - ) - await store.upsert_constraint( - record=record, - event={"action": "memory_component_add"}, - ) - - async def clear(self) -> None: - self._planning_state = {} - - async def close(self) -> None: - return - - -__all__ = ["ConstraintPlanningMemory"] diff --git a/src/fateforger/agents/timeboxing/constraint_reconciliation.py b/src/fateforger/agents/timeboxing/constraint_reconciliation.py deleted file mode 100644 index 42d0d9e7..00000000 --- a/src/fateforger/agents/timeboxing/constraint_reconciliation.py +++ /dev/null @@ -1,295 +0,0 @@ -"""Constraint reconciliation and applicability filtering utilities.""" - -from __future__ import annotations - -from dataclasses import dataclass -from datetime import date, datetime, timezone -import json -from typing import Any - - -_STATUS_RANK = {"locked": 0, "proposed": 1, "declined": 2} -_NECESSITY_RANK = {"must": 0, "should": 1, "prefer": 2} -_WEEKDAY_CODES = ("MO", "TU", "WE", "TH", "FR", "SA", "SU") - - -@dataclass(frozen=True, slots=True) -class ReconciledConstraintRows: - """Deterministic reconciliation result for durable constraint rows.""" - - raw_count: int - canonical_count: int - applicable_count: int - duplicate_groups: list[dict[str, Any]] - canonical_rows: list[dict[str, Any]] - applicable_rows: list[dict[str, Any]] - - -def _to_text(value: Any) -> str: - return str(value or "").strip() - - -def _to_list(value: Any) -> list[Any]: - if value is None: - return [] - if isinstance(value, list): - return list(value) - if isinstance(value, tuple): - return list(value) - if isinstance(value, set): - return list(value) - return [value] - - -def _parse_iso_date(value: Any) -> date | None: - text = _to_text(value) - if not text: - return None - try: - return date.fromisoformat(text) - except ValueError: - return None - - -def _parse_iso_ts(value: Any) -> float: - text = _to_text(value) - if not text: - return 0.0 - try: - parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) - except ValueError: - return 0.0 - if parsed.tzinfo is None: - parsed = parsed.replace(tzinfo=timezone.utc) - return parsed.timestamp() - - -def _normalize_windows(values: Any) -> list[tuple[str, str, str]]: - out: list[tuple[str, str, str]] = [] - for item in _to_list(values): - if not isinstance(item, dict): - continue - out.append( - ( - _to_text(item.get("kind")).lower(), - _to_text(item.get("start_time_local")), - _to_text(item.get("end_time_local")), - ) - ) - return sorted(out) - - -def _record_from_row(row: dict[str, Any]) -> dict[str, Any]: - nested = row.get("constraint_record") - if isinstance(nested, dict): - return dict(nested) - applicability = { - "start_date": row.get("start_date"), - "end_date": row.get("end_date"), - "days_of_week": list(_to_list(row.get("days_of_week"))), - "timezone": row.get("timezone"), - "recurrence": row.get("recurrence"), - } - payload = { - "rule_kind": row.get("rule_kind") or row.get("type_id"), - "windows": list(_to_list(row.get("windows"))), - "scalar_params": dict(row.get("scalar_params") or {}), - } - lifecycle = {"uid": row.get("uid")} - return { - "name": row.get("name"), - "description": row.get("description"), - "necessity": row.get("necessity"), - "status": row.get("status"), - "source": row.get("source"), - "scope": row.get("scope"), - "topics": list(_to_list(row.get("topics"))), - "confidence": row.get("confidence"), - "applies_stages": list(_to_list(row.get("applies_stages"))), - "applies_event_types": list(_to_list(row.get("applies_event_types"))), - "aspect_classification": row.get("aspect_classification"), - "applicability": applicability, - "payload": payload, - "lifecycle": lifecycle, - } - - -def _semantic_key(record: dict[str, Any]) -> str: - applicability = dict(record.get("applicability") or {}) - payload = dict(record.get("payload") or {}) - scalar_params = dict(payload.get("scalar_params") or {}) - key_payload = { - "scope": _to_text(record.get("scope")).lower(), - "rule_kind": _to_text(payload.get("rule_kind")).lower(), - "name": _to_text(record.get("name")).lower(), - "description": " ".join(_to_text(record.get("description")).lower().split()), - "topics": sorted( - _to_text(item).lower() - for item in _to_list(record.get("topics")) - if _to_text(item) - ), - "applies_stages": sorted( - _to_text(item) - for item in _to_list(record.get("applies_stages")) - if _to_text(item) - ), - "applies_event_types": sorted( - _to_text(item) - for item in _to_list(record.get("applies_event_types")) - if _to_text(item) - ), - "days_of_week": sorted( - _to_text(item).upper() - for item in _to_list(applicability.get("days_of_week")) - if _to_text(item) - ), - "start_date": _to_text(applicability.get("start_date")), - "end_date": _to_text(applicability.get("end_date")), - "timezone": _to_text(applicability.get("timezone")), - "recurrence": _to_text(applicability.get("recurrence")), - "windows": _normalize_windows(payload.get("windows")), - "duration_min": scalar_params.get("duration_min"), - "duration_max": scalar_params.get("duration_max"), - "contiguity": _to_text(scalar_params.get("contiguity")).lower(), - } - return json.dumps(key_payload, sort_keys=True, separators=(",", ":")) - - -def _rank_key(entry: dict[str, Any]) -> tuple[int, int, float, str]: - record = entry["constraint_record"] - status = _to_text(record.get("status")).lower() - necessity = _to_text(record.get("necessity")).lower() - updated_at = max( - _parse_iso_ts(entry.get("updated_at")), - _parse_iso_ts((entry.get("metadata") or {}).get("updated_at")), - ) - return ( - _STATUS_RANK.get(status, 3), - _NECESSITY_RANK.get(necessity, 3), - -updated_at, - _to_text(entry.get("uid")), - ) - - -def _row_from_record(entry: dict[str, Any]) -> dict[str, Any]: - record = entry["constraint_record"] - applicability = dict(record.get("applicability") or {}) - payload = dict(record.get("payload") or {}) - lifecycle = dict(record.get("lifecycle") or {}) - uid = _to_text(entry.get("uid")) or _to_text(lifecycle.get("uid")) - out = { - "uid": uid, - "name": record.get("name"), - "description": record.get("description"), - "necessity": record.get("necessity"), - "status": record.get("status"), - "source": record.get("source"), - "scope": record.get("scope"), - "start_date": applicability.get("start_date"), - "end_date": applicability.get("end_date"), - "days_of_week": list(_to_list(applicability.get("days_of_week"))), - "timezone": applicability.get("timezone"), - "recurrence": applicability.get("recurrence"), - "rule_kind": payload.get("rule_kind"), - "type_id": record.get("type_id") or payload.get("rule_kind"), - "topics": list(_to_list(record.get("topics"))), - "confidence": record.get("confidence"), - "applies_stages": list(_to_list(record.get("applies_stages"))), - "applies_event_types": list(_to_list(record.get("applies_event_types"))), - "aspect_classification": record.get("aspect_classification"), - "updated_at": entry.get("updated_at"), - } - return {key: value for key, value in out.items() if value is not None} - - -def _is_applicable( - *, - row: dict[str, Any], - planned_day: date, - stage: str | None, -) -> bool: - status = _to_text(row.get("status")).lower() - if status and status not in {"locked", "proposed"}: - return False - start = _parse_iso_date(row.get("start_date")) - end = _parse_iso_date(row.get("end_date")) - if start and planned_day < start: - return False - if end and planned_day > end: - return False - allowed_days = { - _to_text(item).upper() - for item in _to_list(row.get("days_of_week")) - if _to_text(item) - } - if allowed_days: - weekday_code = _WEEKDAY_CODES[planned_day.weekday()] - if weekday_code not in allowed_days: - return False - stage_value = _to_text(stage) - applies_stages = { - _to_text(item) for item in _to_list(row.get("applies_stages")) if _to_text(item) - } - if stage_value and applies_stages and stage_value not in applies_stages: - return False - return True - - -def reconcile_constraint_rows( - *, - rows: list[dict[str, Any]], - planned_day: date, - stage: str | None, -) -> ReconciledConstraintRows: - """Canonicalize and applicability-filter raw durable rows.""" - grouped: dict[str, list[dict[str, Any]]] = {} - raw_count = 0 - for row in rows or []: - if not isinstance(row, dict): - continue - raw_count += 1 - record = _record_from_row(row) - lifecycle = dict(record.get("lifecycle") or {}) - uid = _to_text(row.get("uid")) or _to_text(lifecycle.get("uid")) - entry = { - "uid": uid, - "constraint_record": record, - "metadata": dict(row.get("metadata") or {}), - "updated_at": row.get("updated_at"), - } - grouped.setdefault(_semantic_key(record), []).append(entry) - - duplicate_groups: list[dict[str, Any]] = [] - canonical_rows: list[dict[str, Any]] = [] - applicable_rows: list[dict[str, Any]] = [] - for entries in grouped.values(): - ranked = sorted(entries, key=_rank_key) - canonical = ranked[0] - canonical_row = _row_from_record(canonical) - canonical_rows.append(canonical_row) - duplicate_uids = [ - _to_text(item.get("uid")) - for item in ranked[1:] - if _to_text(item.get("uid")) - ] - if duplicate_uids: - duplicate_groups.append( - { - "canonical_uid": _to_text(canonical.get("uid")), - "duplicate_uids": duplicate_uids, - } - ) - if _is_applicable(row=canonical_row, planned_day=planned_day, stage=stage): - applicable_rows.append(canonical_row) - - return ReconciledConstraintRows( - raw_count=raw_count, - canonical_count=len(canonical_rows), - applicable_count=len(applicable_rows), - duplicate_groups=duplicate_groups, - canonical_rows=canonical_rows, - applicable_rows=applicable_rows, - ) - - -__all__ = ["ReconciledConstraintRows", "reconcile_constraint_rows"] diff --git a/src/fateforger/agents/timeboxing/constraint_retriever.py b/src/fateforger/agents/timeboxing/constraint_retriever.py deleted file mode 100644 index 091abffe..00000000 --- a/src/fateforger/agents/timeboxing/constraint_retriever.py +++ /dev/null @@ -1,257 +0,0 @@ -"""Gap-driven durable constraint retrieval for timeboxing. - -This module provides a deterministic retriever that queries the Notion-backed -constraint-memory MCP server using structured filters and routing metadata. - -It is "gap-driven" in the sense that it derives a small query plan from the -current planning context (stage + presence of gaps/blocks/immovables) and uses -`constraint_query_types` to select the most relevant constraint type_ids before -querying constraints. - -This is not "NLU": it does not interpret free-form user text. Natural language -interpretation remains LLM-driven (see `nlu.py`). -""" - -from __future__ import annotations - -from datetime import date -from enum import Enum -from typing import Any, Iterable, List, Optional, Sequence - -from pydantic import BaseModel, Field - -from fateforger.agents.timeboxing.constants import TIMEBOXING_LIMITS -from fateforger.agents.timeboxing.contracts import BlockPlan, Immovable, SleepTarget, WorkWindow -from fateforger.agents.timeboxing.mcp_clients import ConstraintMemoryClient -from fateforger.agents.timeboxing.stage_gating import TimeboxingStage - -STARTUP_PREFETCH_TAG = "startup_prefetch" - - -class ConstraintEventType(str, Enum): - """Constraint event types (Notion schema codes).""" - - MEETING = "M" - COMMUTE = "C" - DEEP_WORK = "DW" - SHALLOW_WORK = "SW" - HABIT = "H" - REST = "R" - BUFFER = "BU" - BREAK = "BG" - PREP = "PR" - - -class ConstraintTypeInfo(BaseModel): - """Type record returned by `constraint_query_types`.""" - - type_id: Optional[str] = None - name: Optional[str] = None - rule_shape: Optional[str] = None - count: int = 0 - requires_windows: bool = False - requires_scalars: List[str] = Field(default_factory=list) - - -class ConstraintQueryPlan(BaseModel): - """Deterministic query plan for durable constraint retrieval.""" - - stage: TimeboxingStage - planned_date: str - event_types_any: List[str] = Field(default_factory=list) - type_ids: List[str] = Field(default_factory=list) - limit: int - - -class ConstraintRetriever: - """Gap-driven retriever for durable constraints.""" - - def __init__( - self, - *, - max_type_ids: int = TIMEBOXING_LIMITS.durable_constraint_type_ids_limit, - query_limit: int = TIMEBOXING_LIMITS.durable_constraint_query_limit, - ) -> None: - """Create a retriever with size limits. - - Args: - max_type_ids: Maximum number of constraint type IDs to request. - query_limit: Maximum constraints to request from the MCP server. - """ - self._max_type_ids = max_type_ids - self._query_limit = query_limit - - def build_plan( - self, - *, - stage: TimeboxingStage, - planned_date: str, - work_window: WorkWindow | None, - sleep_target: SleepTarget | None, - immovables: Sequence[Immovable], - block_plan: BlockPlan | None, - frame_facts: dict[str, Any], - ) -> ConstraintQueryPlan: - """Build a deterministic query plan from planning context.""" - event_types = self._derive_event_types( - stage=stage, - work_window=work_window, - sleep_target=sleep_target, - immovables=immovables, - block_plan=block_plan, - frame_facts=frame_facts, - ) - return ConstraintQueryPlan( - stage=stage, - planned_date=planned_date, - event_types_any=event_types, - type_ids=[], - limit=self._query_limit, - ) - - async def retrieve( - self, - *, - client: ConstraintMemoryClient, - stage: TimeboxingStage, - planned_day: date, - work_window: WorkWindow | None, - sleep_target: SleepTarget | None, - immovables: Sequence[Immovable], - block_plan: BlockPlan | None, - frame_facts: dict[str, Any], - ) -> tuple[ConstraintQueryPlan, list[dict[str, Any]]]: - """Retrieve durable constraints for a stage, returning (plan, raw records).""" - planned_date = planned_day.isoformat() - plan = self.build_plan( - stage=stage, - planned_date=planned_date, - work_window=work_window, - sleep_target=sleep_target, - immovables=immovables, - block_plan=block_plan, - frame_facts=frame_facts, - ) - query_event_types = list(plan.event_types_any or []) - if stage == TimeboxingStage.COLLECT_CONSTRAINTS: - # Stage 1 prefetch is deterministic and startup-focused; event-type routing - # is too restrictive for defaults like sleep/work-window. - query_event_types = [] - if stage == TimeboxingStage.COLLECT_CONSTRAINTS: - # Stage 1 startup prefetch is deterministic and startup-tag driven; avoid - # extra type lookup RPCs on the critical path. - type_ids = [] - else: - type_ids = await self._select_type_ids( - client=client, - stage=stage, - event_types=query_event_types, - max_type_ids=self._max_type_ids, - ) - plan.type_ids = type_ids - filters = { - "as_of": planned_date, - "stage": stage.value, - "event_types_any": query_event_types, - "statuses_any": ["locked", "proposed"], - "require_active": True, - } - if stage == TimeboxingStage.COLLECT_CONSTRAINTS: - filters["scopes_any"] = ["profile", "datespan"] - startup_records = await client.query_constraints( - filters=filters, - type_ids=plan.type_ids, - tags=[STARTUP_PREFETCH_TAG], - sort=[["Status", "descending"]], - limit=plan.limit, - ) - if startup_records: - return plan, self._dedupe_rows_by_uid(startup_records) - records = await client.query_constraints( - filters=filters, - type_ids=plan.type_ids, - tags=None, - sort=[["Status", "descending"]], - limit=plan.limit, - ) - return plan, records - - @staticmethod - def _dedupe_rows_by_uid(rows: Sequence[dict[str, Any]]) -> list[dict[str, Any]]: - """Deduplicate row dicts by uid while preserving first-seen order.""" - out: list[dict[str, Any]] = [] - seen: set[str] = set() - for row in rows or []: - if not isinstance(row, dict): - continue - uid = str(row.get("uid") or "").strip() - if uid: - if uid in seen: - continue - seen.add(uid) - out.append(row) - return out - - async def _select_type_ids( - self, - *, - client: ConstraintMemoryClient, - stage: TimeboxingStage, - event_types: Sequence[str], - max_type_ids: int, - ) -> list[str]: - """Select relevant type_ids via `constraint_query_types`.""" - raw = await client.query_types(stage=stage.value, event_types=list(event_types or [])) - parsed = [ConstraintTypeInfo.model_validate(item) for item in (raw or [])] - type_ids = [t.type_id for t in parsed if isinstance(t.type_id, str) and t.type_id] - # Keep order as returned (already ranked by count). - return list(type_ids)[: max(0, max_type_ids)] - - def _derive_event_types( - self, - *, - stage: TimeboxingStage, - work_window: WorkWindow | None, - sleep_target: SleepTarget | None, - immovables: Sequence[Immovable], - block_plan: BlockPlan | None, - frame_facts: dict[str, Any], - ) -> list[str]: - """Derive a small set of Notion event type codes relevant for the stage.""" - has_immovables = bool(list(immovables or [])) - has_blocks = bool( - (block_plan and ((block_plan.deep_blocks or 0) > 0 or (block_plan.shallow_blocks or 0) > 0)) - ) - has_commutes = bool(frame_facts.get("commutes") or []) - has_habits = bool(frame_facts.get("habits") or []) - has_sleep = sleep_target is not None - has_work_window = work_window is not None - has_gaps = has_work_window and (has_immovables or has_blocks) - - base: set[str] = set() - if stage in (TimeboxingStage.CAPTURE_INPUTS, TimeboxingStage.SKELETON, TimeboxingStage.REFINE, TimeboxingStage.REVIEW_COMMIT): - base.update([ConstraintEventType.DEEP_WORK.value, ConstraintEventType.SHALLOW_WORK.value]) - if stage in (TimeboxingStage.COLLECT_CONSTRAINTS, TimeboxingStage.SKELETON, TimeboxingStage.REFINE, TimeboxingStage.REVIEW_COMMIT): - if has_immovables: - base.add(ConstraintEventType.MEETING.value) - if has_commutes: - base.add(ConstraintEventType.COMMUTE.value) - if has_sleep: - base.add(ConstraintEventType.REST.value) - if has_habits: - base.add(ConstraintEventType.HABIT.value) - if stage in (TimeboxingStage.SKELETON, TimeboxingStage.REFINE, TimeboxingStage.REVIEW_COMMIT) and has_gaps: - base.update([ConstraintEventType.BUFFER.value, ConstraintEventType.BREAK.value]) - - # Always allow "prep" constraints to be retrieved for scheduling stages. - if stage in (TimeboxingStage.SKELETON, TimeboxingStage.REFINE, TimeboxingStage.REVIEW_COMMIT): - base.add(ConstraintEventType.PREP.value) - - return sorted(base) - - -__all__ = [ - "ConstraintEventType", - "ConstraintQueryPlan", - "ConstraintRetriever", -] diff --git a/src/fateforger/agents/timeboxing/constraint_search_tool.py b/src/fateforger/agents/timeboxing/constraint_search_tool.py deleted file mode 100644 index 372d9047..00000000 --- a/src/fateforger/agents/timeboxing/constraint_search_tool.py +++ /dev/null @@ -1,404 +0,0 @@ -"""Conversational constraint search tool for the timeboxing agent. - -This module provides a FunctionTool-compatible search function that stage-gating -LLMs can invoke to find relevant durable constraints in the Notion store. - -The tool accepts a structured search plan (multiple query facets) and executes -them in parallel against the constraint-memory MCP server. Results are -deduplicated, formatted as human-scannable summaries, and returned to the -calling agent for review/selection. - -Design decisions: -- Search is LLM-driven: the agent generates candidate queries based on session - context (planned date, stage, topics, user utterances). -- The MCP server already supports ``text_query`` (Name/Description contains), - ``event_types_any``, ``statuses_any``, ``tags``, and ``type_ids`` filters. -- Results are summarised as compact one-liners the LLM can reason about. -- The tool is idempotent: calling it multiple times refines the search. -""" - -from __future__ import annotations - -import asyncio -import logging -from datetime import date -from typing import Any, Dict, List, Optional, Sequence - -from pydantic import BaseModel, ConfigDict, Field - -from fateforger.agents.timeboxing.mcp_clients import ConstraintMemoryClient - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Search plan model (the agent fills this in) -# --------------------------------------------------------------------------- - - -class ConstraintSearchQuery(BaseModel): - """A single search facet within a search plan.""" - - model_config = ConfigDict(extra="forbid") - - label: str = Field( - description="Short human-readable label for this query (e.g. 'deep work rules').", - ) - text_query: Optional[str] = Field( - description="Free-text substring to match against constraint Name or Description.", - ) - event_types: Optional[List[str]] = Field( - description=( - "Notion event-type codes to filter by. " - "Options: M (meeting), C (commute), DW (deep work), SW (shallow work), " - "H (habit), R (rest), BU (buffer), BG (break), PR (prep)." - ), - ) - tags: Optional[List[str]] = Field( - description="Topic tag names to filter by (e.g. ['focus', 'meals']).", - ) - statuses: Optional[List[str]] = Field( - description="Constraint statuses to include. Options: 'locked', 'proposed'. Default: both.", - ) - scopes: Optional[List[str]] = Field( - description="Constraint scopes to include. Options: 'session', 'profile', 'datespan'.", - ) - necessities: Optional[List[str]] = Field( - description="Necessity levels to include. Options: 'must', 'should'.", - ) - limit: int = Field( - description="Maximum number of results for this query.", - ) - - -class ConstraintSearchPlan(BaseModel): - """A set of parallel search queries the agent wants to execute.""" - - model_config = ConfigDict(extra="forbid") - - queries: List[ConstraintSearchQuery] = Field( - min_length=1, - max_length=8, - description="One or more search facets to execute in parallel.", - ) - planned_date: Optional[str] = Field( - default=None, - description="ISO date (YYYY-MM-DD) for active-window filtering. Defaults to today.", - ) - stage: Optional[str] = Field( - default=None, - description="Current timeboxing stage (e.g. 'Skeleton', 'Refine').", - ) - - -# --------------------------------------------------------------------------- -# Search result model -# --------------------------------------------------------------------------- - - -class ConstraintSearchResult(BaseModel): - """A single constraint returned from a search.""" - - uid: Optional[str] = None - name: Optional[str] = None - description: Optional[str] = None - necessity: Optional[str] = None - status: Optional[str] = None - scope: Optional[str] = None - rule_kind: Optional[str] = None - type_id: Optional[str] = None - days_of_week: List[str] = Field(default_factory=list) - start_date: Optional[str] = None - end_date: Optional[str] = None - topics: List[str] = Field(default_factory=list) - page_id: Optional[str] = None - - -class ConstraintSearchResponse(BaseModel): - """Aggregated search results returned to the agent.""" - - total_found: int = 0 - constraints: List[ConstraintSearchResult] = Field(default_factory=list) - queries_executed: int = 0 - errors: List[str] = Field(default_factory=list) - summary: str = "" - - -# --------------------------------------------------------------------------- -# Formatting helpers -# --------------------------------------------------------------------------- - - -def format_constraint_oneliner(c: ConstraintSearchResult) -> str: - """Render a constraint as a compact one-liner for agent review. - - Format: [status|necessity] name β€” description (scope, rule_kind, days) - """ - parts: list[str] = [] - - # Status + necessity badge - badge_parts: list[str] = [] - if c.status: - badge_parts.append(c.status) - if c.necessity: - badge_parts.append(c.necessity) - if badge_parts: - parts.append(f"[{' | '.join(badge_parts)}]") - - # Name - parts.append(c.name or "(unnamed)") - - # Description snippet (truncated) - if c.description: - desc = c.description[:80].rstrip() - if len(c.description) > 80: - desc += "…" - parts.append(f"β€” {desc}") - - # Metadata tags - meta: list[str] = [] - if c.scope: - meta.append(f"scope={c.scope}") - if c.rule_kind: - meta.append(f"kind={c.rule_kind}") - if c.days_of_week: - meta.append(f"days={','.join(c.days_of_week)}") - if c.topics: - meta.append(f"topics={','.join(c.topics[:3])}") - if c.start_date or c.end_date: - date_range = f"{c.start_date or '…'}β†’{c.end_date or '…'}" - meta.append(f"dates={date_range}") - if meta: - parts.append(f"({'; '.join(meta)})") - - return " ".join(parts) - - -def format_search_summary(results: list[ConstraintSearchResult]) -> str: - """Render all search results as a numbered list of one-liners. - - Args: - results: List of search results to format. - - Returns: - A multi-line string with numbered constraint summaries. - """ - if not results: - return "No constraints found matching the search criteria." - lines: list[str] = [] - for i, c in enumerate(results, 1): - lines.append(f"{i}. {format_constraint_oneliner(c)}") - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# Core search execution -# --------------------------------------------------------------------------- - - -def _raw_to_result(raw: Dict[str, Any]) -> ConstraintSearchResult: - """Convert a raw MCP constraint dict to a typed search result.""" - return ConstraintSearchResult( - uid=raw.get("uid"), - name=raw.get("name"), - description=raw.get("description"), - necessity=raw.get("necessity"), - status=raw.get("status"), - scope=raw.get("scope"), - rule_kind=raw.get("rule_kind"), - type_id=raw.get("type_id"), - days_of_week=raw.get("days_of_week") or [], - start_date=raw.get("start_date"), - end_date=raw.get("end_date"), - topics=raw.get("topics") or [], - page_id=raw.get("page_id"), - ) - - -def _dedupe_results( - results: Sequence[ConstraintSearchResult], -) -> list[ConstraintSearchResult]: - """Deduplicate results by UID, preserving first-seen order.""" - seen: set[str] = set() - unique: list[ConstraintSearchResult] = [] - for r in results: - key = r.uid or r.page_id or r.name or "" - if key and key in seen: - continue - if key: - seen.add(key) - unique.append(r) - return unique - - -async def _execute_single_query( - client: ConstraintMemoryClient, - query: ConstraintSearchQuery, - *, - as_of: str, - stage: str | None, -) -> list[ConstraintSearchResult]: - """Execute a single search query against the MCP server. - - Args: - client: The constraint-memory MCP client. - query: A single search facet to execute. - as_of: ISO date string for active-window filtering. - stage: Optional current timeboxing stage. - - Returns: - A list of typed search results. - """ - filters: Dict[str, Any] = { - "as_of": as_of, - "require_active": True, - } - if stage: - filters["stage"] = stage - if query.text_query: - filters["text_query"] = query.text_query - if query.event_types: - filters["event_types_any"] = query.event_types - if query.statuses: - filters["statuses_any"] = query.statuses - if query.scopes: - filters["scopes_any"] = query.scopes - if query.necessities: - filters["necessities_any"] = query.necessities - - raw_results = await client.query_constraints( - filters=filters, - tags=query.tags, - sort=[["Status", "descending"]], - limit=query.limit, - ) - - return [_raw_to_result(r) for r in raw_results] - - -async def execute_search_plan( - client: ConstraintMemoryClient, - plan: ConstraintSearchPlan, -) -> ConstraintSearchResponse: - """Execute a full search plan (parallel queries), deduplicate, and summarise. - - Args: - client: The constraint-memory MCP client. - plan: The search plan with one or more query facets. - - Returns: - A response containing deduplicated results and a formatted summary. - """ - as_of = plan.planned_date or date.today().isoformat() - - async def _run(query: ConstraintSearchQuery): - try: - results = await _execute_single_query( - client, query, as_of=as_of, stage=plan.stage - ) - return query.label, results - except Exception as exc: - return query.label, exc - - tasks = [_run(query) for query in plan.queries] - outputs = await asyncio.gather(*tasks) - - all_results: list[ConstraintSearchResult] = [] - errors: list[str] = [] - for label, payload in outputs: - if isinstance(payload, BaseException): - errors.append(f"{label}: {type(payload).__name__}: {payload}") - continue - all_results.extend(payload) - - unique = _dedupe_results(all_results) - summary = format_search_summary(unique) - - return ConstraintSearchResponse( - total_found=len(unique), - constraints=unique, - queries_executed=len(plan.queries), - errors=errors, - summary=summary, - ) - - -# --------------------------------------------------------------------------- -# FunctionTool-compatible wrapper -# --------------------------------------------------------------------------- - - -async def search_constraints( - queries: list[dict[str, Any]], - planned_date: str | None = None, - stage: str | None = None, - _client: ConstraintMemoryClient | None = None, -) -> str: - """Search the durable constraint store with one or more query facets. - - This tool lets the timeboxing agent find relevant constraints by combining - text search, event-type filtering, tags, and status/scope filters. - - Args: - queries: List of search facets. Each facet is a dict with optional keys: - - label (str): Short description of this query. - - text_query (str): Free-text search on Name/Description. - - event_types (list[str]): Event-type codes (M, DW, SW, H, R, etc.). - - tags (list[str]): Topic tag names. - - statuses (list[str]): 'locked' and/or 'proposed'. - - scopes (list[str]): 'session', 'profile', 'datespan'. - - necessities (list[str]): 'must' and/or 'should'. - - limit (int): Max results per facet (default 20). - planned_date: ISO date string (YYYY-MM-DD) for active-window filtering. - Null means today. - stage: Current timeboxing stage (e.g. 'Skeleton'). Null means unfiltered. - _client: Internal β€” injected by the agent. Do not set. - - Returns: - A formatted summary of matching constraints (numbered list of one-liners), - or an error message if no client is available. - """ - if _client is None: - return ( - "Error: Constraint memory client not available. Cannot search constraints." - ) - - parsed_queries = [ - ConstraintSearchQuery( - label=q.get("label", f"query-{i}"), - text_query=q.get("text_query"), - event_types=q.get("event_types"), - tags=q.get("tags"), - statuses=q.get("statuses"), - scopes=q.get("scopes"), - necessities=q.get("necessities"), - limit=q.get("limit", 20), - ) - for i, q in enumerate(queries, 1) - ] - - plan = ConstraintSearchPlan( - queries=parsed_queries, - planned_date=planned_date or None, - stage=stage or None, - ) - - response = await execute_search_plan(_client, plan) - - header = f"Found {response.total_found} constraint(s) across {response.queries_executed} search(es):\n\n" - if response.errors: - lines = [f"{i}. {msg}" for i, msg in enumerate(response.errors, 1)] - header += f"ERRORS ({len(response.errors)}):\n" + "\n".join(lines) + "\n\n" - return header + response.summary - - -__all__ = [ - "ConstraintSearchPlan", - "ConstraintSearchQuery", - "ConstraintSearchResponse", - "ConstraintSearchResult", - "execute_search_plan", - "format_constraint_oneliner", - "format_search_summary", - "search_constraints", -] diff --git a/src/fateforger/agents/timeboxing/contracts.py b/src/fateforger/agents/timeboxing/contracts.py deleted file mode 100644 index 00336cb9..00000000 --- a/src/fateforger/agents/timeboxing/contracts.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Typed stage context contracts for the timeboxing coordinator and LLM stages.""" - -from __future__ import annotations - -from datetime import date -from typing import Any, Dict, List, Literal, Optional - -from pydantic import BaseModel, Field - -from fateforger.agents.timeboxing.preferences import Constraint - - -class WorkWindow(BaseModel): - """Canonical work window for a planning day.""" - - start: str = Field(..., description="HH:MM") - end: str = Field(..., description="HH:MM") - - -class SleepTarget(BaseModel): - """Optional sleep target for a planning day.""" - - start: Optional[str] = Field(default=None, description="HH:MM") - end: Optional[str] = Field(default=None, description="HH:MM") - hours: Optional[float] = Field(default=None) - - -class Immovable(BaseModel): - """Normalized calendar immovable event for timeboxing.""" - - title: str - start: str = Field(..., description="HH:MM") - end: str = Field(..., description="HH:MM") - - -class BlockPlan(BaseModel): - """Block-based planning settings for the day.""" - - deep_blocks: Optional[int] = None - shallow_blocks: Optional[int] = None - block_minutes: Optional[int] = None - focus_theme: Optional[str] = None - - -class TaskCandidate(BaseModel): - """Candidate task input for block assignment.""" - - title: str - block_count: Optional[int] = None - duration_min: Optional[int] = None - due: Optional[str] = Field(default=None, description="YYYY-MM-DD") - importance: Optional[Literal["high", "med", "low"]] = None - - -class DailyOneThing(BaseModel): - """Daily One Thing for block allocation.""" - - title: str - block_count: Optional[int] = None - duration_min: Optional[int] = None - - -class CollectConstraintsContext(BaseModel): - """Input contract for Stage 1 (CollectConstraints) gating agent.""" - - stage_id: Literal["CollectConstraints"] = "CollectConstraints" - user_message: str - facts: Dict[str, Any] = Field(default_factory=dict) - immovables: List[Immovable] = Field(default_factory=list) - durable_constraints: List[Constraint] = Field(default_factory=list) - - -class CaptureInputsContext(BaseModel): - """Input contract for Stage 2 (CaptureInputs) gating agent.""" - - stage_id: Literal["CaptureInputs"] = "CaptureInputs" - user_message: str - frame_facts: Dict[str, Any] = Field(default_factory=dict) - input_facts: Dict[str, Any] = Field(default_factory=dict) - - -class SkeletonContext(BaseModel): - """Input contract for Stage 3 (Skeleton) draft agent.""" - - stage_id: Literal["Skeleton"] = "Skeleton" - date: date - timezone: str - work_window: Optional[WorkWindow] = None - sleep_target: Optional[SleepTarget] = None - immovables: List[Immovable] = Field(default_factory=list) - block_plan: Optional[BlockPlan] = None - daily_one_thing: Optional[DailyOneThing] = None - tasks: List[TaskCandidate] = Field(default_factory=list) - constraints_snapshot: List[Constraint] = Field(default_factory=list) - - -__all__ = [ - "BlockPlan", - "CaptureInputsContext", - "CollectConstraintsContext", - "DailyOneThing", - "Immovable", - "SkeletonContext", - "SleepTarget", - "TaskCandidate", - "WorkWindow", -] diff --git a/src/fateforger/agents/timeboxing/flow.py b/src/fateforger/agents/timeboxing/flow.py deleted file mode 100644 index 4b457367..00000000 --- a/src/fateforger/agents/timeboxing/flow.py +++ /dev/null @@ -1,172 +0,0 @@ -"""GraphFlow builder for the timeboxing workflow.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional, Type - -from autogen_agentchat.agents import AssistantAgent -from autogen_agentchat.conditions import MaxMessageTermination -from autogen_agentchat.messages import TextMessage -from autogen_agentchat.teams import DiGraphBuilder, GraphFlow -from autogen_ext.models.openai import OpenAIChatCompletionClient -from pydantic import BaseModel - -from fateforger.llm import assert_strict_tools_for_structured_output - -from .timebox import Timebox -from .prompts import ( - ASSESS_PROMPT, - APPROVAL_PROMPT, - DONE_PROMPT, - DRAFT_PROMPT, - HYDRATE_PROMPT, - REVIEW_PROMPT, - SUBMIT_PROMPT, - TIMEBOXING_SYSTEM_PROMPT, -) - - -@dataclass -class PlanningState: - """In-memory state shared across the graph flow.""" - - thread_ts: str - channel_id: str - user_id: str - user_input: str - metadata: Dict[str, Any] = field(default_factory=dict) - timebox_json: Dict[str, Any] | None = None - approval: bool | None = None - - -class ApprovalDecision(BaseModel): - approved: bool - message: str - - -class SubmitDecision(BaseModel): - submitted: bool - failed: bool - message: str - - -def _approved(message) -> bool: - content = getattr(message, "content", None) - if isinstance(content, ApprovalDecision): - return bool(content.approved) - if isinstance(content, dict): - return bool(content.get("approved") is True) - return False - - -def _declined(message) -> bool: - content = getattr(message, "content", None) - if isinstance(content, ApprovalDecision): - return not content.approved - if isinstance(content, dict): - approved = content.get("approved") - return approved is False - return False - - -def _submitted(message) -> bool: - content = getattr(message, "content", None) - if isinstance(content, SubmitDecision): - return bool(content.submitted) and not bool(content.failed) - if isinstance(content, dict): - return bool(content.get("submitted") is True) and not bool(content.get("failed") is True) - return False - - -def _submit_failed(message) -> bool: - content = getattr(message, "content", None) - if isinstance(content, SubmitDecision): - return bool(content.failed) - if isinstance(content, dict): - return bool(content.get("failed") is True) - return False - - -def _build_node( - name: str, - prompt: str, - model_client: OpenAIChatCompletionClient, - *, - output_content_type: Optional[Type] = None, - tools: Optional[List] = None, -) -> AssistantAgent: - """Create an AssistantAgent configured for a specific phase.""" - assert_strict_tools_for_structured_output( - tools=tools, - output_content_type=output_content_type, - agent_name=name, - ) - - return AssistantAgent( - name=name, - system_message=f"{TIMEBOXING_SYSTEM_PROMPT}\n\n{prompt}", - model_client=model_client, - tools=tools, - output_content_type=output_content_type, - reflect_on_tool_use=False, - max_tool_iterations=1, - ) - - -def build_timeboxing_flow( - model_client: OpenAIChatCompletionClient, *, tools: Optional[List] = None -) -> GraphFlow: - """Construct the directed graph coordinating the timeboxing workflow.""" - - builder = DiGraphBuilder() - - hydrate = _build_node("HydrateContext", HYDRATE_PROMPT, model_client, tools=tools) - assess = _build_node("AssessReadiness", ASSESS_PROMPT, model_client, tools=tools) - draft = _build_node( - "DraftTimebox", - DRAFT_PROMPT, - model_client, - output_content_type=Timebox, - tools=tools, - ) - review = _build_node("ReviewWithUser", REVIEW_PROMPT, model_client, tools=tools) - approve = _build_node( - "ApprovalGate", - APPROVAL_PROMPT, - model_client, - output_content_type=ApprovalDecision, - tools=tools, - ) - submit = _build_node( - "SubmitToCalendar", - SUBMIT_PROMPT, - model_client, - output_content_type=SubmitDecision, - tools=tools, - ) - done = _build_node("Done", DONE_PROMPT, model_client, tools=tools) - - for agent in (hydrate, assess, draft, review, approve, submit, done): - builder.add_node(agent) - - builder.add_edge(hydrate, assess) - builder.add_edge(assess, draft) - builder.add_edge(draft, review) - builder.add_edge(review, approve) - builder.add_edge(approve, submit, condition=_approved) - builder.add_edge(approve, done, condition=_declined) - builder.add_edge(submit, done, condition=_submitted) - builder.add_edge(submit, review, condition=_submit_failed) - - builder.set_entry_point(hydrate) - graph = builder.build() - - return GraphFlow( - participants=builder.get_participants(), - graph=graph, - termination_condition=MaxMessageTermination(20), - ) - - -__all__ = ["PlanningState", "build_timeboxing_flow"] diff --git a/src/fateforger/agents/timeboxing/flow_graph.py b/src/fateforger/agents/timeboxing/flow_graph.py deleted file mode 100644 index e7d337da..00000000 --- a/src/fateforger/agents/timeboxing/flow_graph.py +++ /dev/null @@ -1,135 +0,0 @@ -"""GraphFlow builder for the stage-gated TimeboxingFlowAgent. - -This module encodes the stage machine declaratively using AutoGen GraphFlow + DiGraphBuilder. - -Key properties: -- One Slack/user turn runs the graph until `PresenterNode` emits a single TextMessage, then stops. -- Stage routing is handled by conditional edges (no monolithic `if/elif` dispatch). -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from autogen_agentchat.conditions import TextMessageTermination -from autogen_agentchat.teams import DiGraphBuilder, GraphFlow - -from fateforger.agents.timeboxing.stage_gating import TimeboxingStage -from fateforger.agents.timeboxing.nodes import ( - DecisionNode, - PresenterNode, - StageCaptureInputsNode, - StageCollectConstraintsNode, - StageRefineNode, - StageReviewCommitNode, - StageSkeletonNode, - TransitionNode, - TurnInitNode, -) - -if TYPE_CHECKING: # pragma: no cover - from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent - - -def build_timeboxing_graphflow( - *, orchestrator: "TimeboxingFlowAgent", session: "Session" -) -> GraphFlow: - """Build a GraphFlow instance that runs exactly one stage per user turn.""" - builder = DiGraphBuilder() - - turn_init = TurnInitNode(orchestrator=orchestrator, session=session) - decision = DecisionNode(orchestrator=orchestrator, session=session, turn_init=turn_init) - transition = TransitionNode(orchestrator=orchestrator, session=session, turn_init=turn_init) - - stage_collect = StageCollectConstraintsNode( - orchestrator=orchestrator, session=session, transition=transition - ) - stage_capture = StageCaptureInputsNode( - orchestrator=orchestrator, session=session, transition=transition - ) - stage_skeleton = StageSkeletonNode( - orchestrator=orchestrator, session=session, transition=transition - ) - stage_refine = StageRefineNode( - orchestrator=orchestrator, session=session, transition=transition - ) - stage_review = StageReviewCommitNode( - orchestrator=orchestrator, session=session, transition=transition - ) - - stages = { - TimeboxingStage.COLLECT_CONSTRAINTS: stage_collect, - TimeboxingStage.CAPTURE_INPUTS: stage_capture, - TimeboxingStage.SKELETON: stage_skeleton, - TimeboxingStage.REFINE: stage_refine, - TimeboxingStage.REVIEW_COMMIT: stage_review, - } - presenter = PresenterNode(orchestrator=orchestrator, session=session, stages=stages) - - for agent in ( - turn_init, - decision, - transition, - stage_collect, - stage_capture, - stage_skeleton, - stage_refine, - stage_review, - presenter, - ): - builder.add_node(agent) - - builder.add_edge(turn_init, decision) - builder.add_edge(decision, transition) - - # If the decision/transition completed the session, skip stage execution and present. - builder.add_edge( - transition, - presenter, - condition=lambda _m: bool(session.completed), - activation_condition="any", - ) - - builder.add_edge( - transition, - stage_collect, - condition=lambda _m: session.stage == TimeboxingStage.COLLECT_CONSTRAINTS and not session.completed, - ) - builder.add_edge( - transition, - stage_capture, - condition=lambda _m: session.stage == TimeboxingStage.CAPTURE_INPUTS and not session.completed, - ) - builder.add_edge( - transition, - stage_skeleton, - condition=lambda _m: session.stage == TimeboxingStage.SKELETON and not session.completed, - ) - builder.add_edge( - transition, - stage_refine, - condition=lambda _m: session.stage == TimeboxingStage.REFINE and not session.completed, - ) - builder.add_edge( - transition, - stage_review, - condition=lambda _m: session.stage == TimeboxingStage.REVIEW_COMMIT and not session.completed, - ) - - builder.add_edge(stage_collect, presenter, activation_condition="any") - builder.add_edge(stage_capture, presenter, activation_condition="any") - builder.add_edge(stage_skeleton, presenter, activation_condition="any") - builder.add_edge(stage_refine, presenter, activation_condition="any") - builder.add_edge(stage_review, presenter, activation_condition="any") - - builder.set_entry_point(turn_init) - graph = builder.build() - - return GraphFlow( - participants=builder.get_participants(), - graph=graph, - termination_condition=TextMessageTermination(source=presenter.name), - ) - - -__all__ = ["build_timeboxing_graphflow"] diff --git a/src/fateforger/agents/timeboxing/mcp_clients.py b/src/fateforger/agents/timeboxing/mcp_clients.py index 2cc254ae..27012df6 100644 --- a/src/fateforger/agents/timeboxing/mcp_clients.py +++ b/src/fateforger/agents/timeboxing/mcp_clients.py @@ -1,6 +1,8 @@ -"""Internal MCP clients used by the timeboxing coordinator. +"""The constraint-memory MCP client. -These are intentionally kept out of `agent.py` to keep orchestration logic readable. +The calendar client and the Stage-4 day snapshot that used to sit beside it +went with the legacy timeboxing agent; this is the tasks defaults-memory +backend now, and nothing else. """ from __future__ import annotations @@ -8,32 +10,14 @@ import asyncio import json import sys -from dataclasses import dataclass -from datetime import date, datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any -from zoneinfo import ZoneInfo +from typing import Any -if TYPE_CHECKING: - from fateforger.core.calendar_preferences import CalendarList - -from dateutil import parser as date_parser - -from fateforger.adapters.calendar.models import GCalEventsResponse -from fateforger.core.logging_config import record_error, record_tool_call from fateforger.tools.constraint_mcp import ( build_constraint_server_env, resolve_constraint_repo_root, ) -@dataclass(frozen=True) -class CalendarDaySnapshot: - """Typed day snapshot used by Stage 4 sync preflight.""" - - response: GCalEventsResponse - immovables: list[dict[str, str]] - - class ConstraintMemoryClient: """Client for the constraint-memory MCP server (stdio workbench).""" @@ -318,388 +302,3 @@ async def upsert_constraint( async def close(self) -> None: """Close the underlying MCP workbench when supported.""" await self._workbench.stop() - - -class McpCalendarClient: - """Client for Google Calendar MCP server (streamable HTTP workbench).""" - - _RECOVERABLE_ERROR_MARKERS = ( - "mcp actor not running", - "all connection attempts failed", - "timed out while waiting for response to clientrequest", - "connection refused", - "server disconnected", - ) - - def __init__(self, *, server_url: str, timeout: float = 10.0) -> None: - """Initialize the calendar MCP workbench. - - Args: - server_url: MCP server base URL. - timeout: HTTP timeout seconds. - """ - self._server_url = server_url - self._timeout = timeout - self._params = self._build_params() - self._workbench = self._build_workbench() - - def _build_params(self): - """Build MCP server params from current server_url and timeout.""" - from autogen_ext.tools.mcp import StreamableHttpServerParams - - return StreamableHttpServerParams(url=self._server_url, timeout=self._timeout) - - def _build_workbench(self): - """Build a fresh MCP workbench from current params.""" - from autogen_ext.tools.mcp import McpWorkbench - - return McpWorkbench(self._params) - - @classmethod - def _is_recoverable_transport_error(cls, exc: Exception) -> bool: - """Return True when the exception is a known transient MCP transport failure.""" - text = str(exc or "").strip().lower() - if not text: - return False - return any(marker in text for marker in cls._RECOVERABLE_ERROR_MARKERS) - - async def _reset_workbench(self) -> None: - """Close the current workbench and create a fresh one for retry.""" - current = self._workbench - close = getattr(current, "close", None) - if callable(close): - maybe = close() - if hasattr(maybe, "__await__"): - await maybe - self._params = self._build_params() - self._workbench = self._build_workbench() - - async def _call_tool_payload( - self, - *, - tool_name: str, - arguments: dict[str, Any], - diagnostics: dict[str, Any] | None = None, - ) -> Any: - """Call an MCP tool and extract its payload, retrying once on recoverable errors.""" - attempts = 2 - for attempt in range(1, attempts + 1): - try: - result = await self._workbench.call_tool(tool_name, arguments=arguments) - if diagnostics is not None: - diagnostics["result_type"] = type(result).__name__ - return self._extract_tool_payload(result) - except Exception as exc: - recoverable = self._is_recoverable_transport_error(exc) - if diagnostics is not None: - attempt_errors = diagnostics.setdefault("attempt_errors", []) - attempt_errors.append( - { - "attempt": attempt, - "recoverable": recoverable, - "error": (str(exc) or type(exc).__name__)[:300], - } - ) - error_type = ( - "transport_recoverable" if recoverable else "transport_fatal" - ) - record_error(component="McpCalendarClient", error_type=error_type) - record_tool_call( - agent="mcp_calendar", - tool=tool_name, - status="error", - ) - if attempt >= attempts or not recoverable: - raise - await self._reset_workbench() - - async def get_tools(self) -> list: - """Return MCP tool definitions for AutoGen tool wiring.""" - from autogen_ext.tools.mcp import mcp_server_tools - - tools = await mcp_server_tools(self._params) - if not tools: - raise RuntimeError("calendar MCP server returned no tools") - return tools - - @staticmethod - def _parse_json_text(raw: Any, *, source: str) -> Any: - """Parse a JSON payload from text, raising on invalid content.""" - if not isinstance(raw, str): - raise RuntimeError( - f"calendar MCP payload from {source} is not text: {type(raw).__name__}" - ) - text = raw.strip() - if not text: - raise RuntimeError(f"calendar MCP payload from {source} is empty") - if text.startswith("Error executing tool "): - raise RuntimeError(f"calendar MCP tool failed: {text}") - if text.startswith("```"): - lines = text.splitlines() - if lines and lines[0].startswith("```"): - lines = lines[1:] - if lines and lines[-1].strip() == "```": - lines = lines[:-1] - text = "\n".join(lines).strip() - try: - return json.loads(text) - except Exception as exc: - raise RuntimeError( - f"calendar MCP payload from {source} is not valid JSON: {text}" - ) from exc - - @classmethod - def _extract_tool_payload(cls, result: Any) -> Any: - """Normalize tool results into a raw payload. - - Raises: - RuntimeError: if no supported payload shape can be decoded. - """ - if isinstance(result, (dict, list)): - return result - to_text = getattr(result, "to_text", None) - if callable(to_text): - return cls._parse_json_text(to_text(), source="tool.to_text") - payload = getattr(result, "content", None) - if payload is not None: - if isinstance(payload, list): - for item in payload: - if isinstance(item, (dict, list)): - return item - item_content = getattr(item, "content", None) - if item_content is not None: - return cls._parse_json_text( - item_content, source="tool.content[].content" - ) - item_text = getattr(item, "text", None) - if item_text is not None: - return cls._parse_json_text( - item_text, source="tool.content[].text" - ) - else: - return cls._parse_json_text(payload, source="tool.content") - payload = getattr(result, "result", None) - if payload is not None: - if isinstance(payload, list): - for item in payload: - if isinstance(item, (dict, list)): - return item - item_content = getattr(item, "content", None) - if item_content is not None: - return cls._parse_json_text( - item_content, source="tool.result[].content" - ) - item_text = getattr(item, "text", None) - if item_text is not None: - return cls._parse_json_text( - item_text, source="tool.result[].text" - ) - else: - return cls._parse_json_text(payload, source="tool.result") - raise RuntimeError( - "calendar MCP tool returned unsupported payload; expected dict/list/JSON text" - ) - - @staticmethod - def _normalize_events(payload: Any) -> list[dict[str, Any]]: - """Coerce raw MCP payloads into a list of event dicts. - - Handles the four shapes returned by the calendar MCP server: - - ``{"events": [...]}`` / ``{"items": [...]}`` β€” wrapped list - - ``{"event": {...}}`` β€” single event wrapper - - ``[{...}, ...]`` β€” bare event list (with optional recursive nesting) - """ - if isinstance(payload, dict): - for key in ("events", "items"): - val = payload.get(key) - if isinstance(val, list): - return [item for item in val if isinstance(item, dict)] - event = payload.get("event") - if isinstance(event, dict): - return [event] - return [] - if isinstance(payload, list): - dict_items = [item for item in payload if isinstance(item, dict)] - if not dict_items: - return [] - direct = [item for item in dict_items if "start" in item and "end" in item] - if direct: - return direct - nested = [ - norm - for item in dict_items - for norm in McpCalendarClient._normalize_events(item) - ] - return nested or dict_items - return [] - - @staticmethod - def _parse_event_dt(raw: dict[str, Any] | None, *, tz: ZoneInfo) -> datetime | None: - """Parse a calendar event datetime payload into a timezone-aware datetime.""" - if not raw: - return None - from fateforger.contracts import EventDateTime # noqa: PLC0415 - - return EventDateTime.model_validate(raw).to_datetime(tz) - - @staticmethod - def _to_hhmm(dt_val: datetime | None, *, tz: ZoneInfo) -> str | None: - """Format an event datetime as HH:MM in the requested timezone.""" - if not dt_val: - return None - return dt_val.astimezone(tz).strftime("%H:%M") - - @staticmethod - def _list_events_args( - *, calendar_id: str, day: date, tz: ZoneInfo - ) -> dict[str, Any]: - start = datetime.combine(day, datetime.min.time(), tz).replace( - tzinfo=None, - microsecond=0, - ) - end = ( - datetime.combine(day, datetime.min.time(), tz) + timedelta(days=1) - ).replace( - tzinfo=None, - microsecond=0, - ) - return { - "calendarId": calendar_id, - "timeMin": start.isoformat(timespec="seconds"), - "timeMax": end.isoformat(timespec="seconds"), - "singleEvents": True, - "orderBy": "startTime", - } - - def _immovables_from_response( - self, - *, - response: GCalEventsResponse, - day: date, - tz: ZoneInfo, - ) -> list[dict[str, str]]: - immovables: list[dict[str, str]] = [] - for event in response.events: - if (event.status or "").lower() == "cancelled": - continue - event_dict = event.model_dump(mode="json", by_alias=True) - summary = str(event.summary or "").strip() or "Busy" - start_dt = self._parse_event_dt(event_dict.get("start"), tz=tz) - end_dt = self._parse_event_dt(event_dict.get("end"), tz=tz) - if not start_dt or not end_dt or end_dt <= start_dt: - continue - if start_dt.date() != day: - continue - start_str = self._to_hhmm(start_dt, tz=tz) - end_str = self._to_hhmm(end_dt, tz=tz) - if not start_str or not end_str: - continue - immovables.append({"title": summary, "start": start_str, "end": end_str}) - return immovables - - async def list_day_snapshot( - self, - *, - calendar_id: str, - day: date, - tz: ZoneInfo, - diagnostics: dict[str, Any] | None = None, - ) -> CalendarDaySnapshot: - """Fetch a typed day snapshot with both raw events and immovables.""" - args = self._list_events_args(calendar_id=calendar_id, day=day, tz=tz) - if diagnostics is not None: - diagnostics["request"] = args - payload = await self._call_tool_payload( - tool_name="list-events", - arguments=args, - diagnostics=diagnostics, - ) - if diagnostics is not None: - diagnostics["payload_type"] = type(payload).__name__ - if isinstance(payload, dict): - diagnostics["payload_keys"] = sorted(payload.keys()) - events = self._normalize_events(payload) - total_count = len(events) - if isinstance(payload, dict): - raw_total = payload.get("totalCount") or payload.get("total_count") - if isinstance(raw_total, int): - total_count = raw_total - try: - response = GCalEventsResponse.model_validate( - { - "events": events, - "totalCount": total_count, - } - ) - except Exception: - response = GCalEventsResponse(events=[], totalCount=0) - immovables = self._immovables_from_response(response=response, day=day, tz=tz) - if diagnostics is not None: - diagnostics["raw_event_count"] = len(response.events) - diagnostics["immovable_count"] = len(immovables) - return CalendarDaySnapshot(response=response, immovables=immovables) - - async def load_list( - self, - *, - list_def: CalendarList, - day: date, - tz: ZoneInfo, - ) -> CalendarDaySnapshot: - """Fetch a day snapshot for all calendars in a CalendarList. - - Passes all calendar IDs in a single list-events call. When multiple - calendars are requested, calendarId is a JSON array string as accepted - by the GCal MCP server. Results are deduplicated by event ID (first - occurrence wins). - """ - calendar_ids = [entry.calendar_id for entry in list_def.calendars] - if not calendar_ids: - return CalendarDaySnapshot( - response=GCalEventsResponse(events=[], totalCount=0), - immovables=[], - ) - - calendar_id_arg = calendar_ids[0] if len(calendar_ids) == 1 else json.dumps(calendar_ids) - snapshot = await self.list_day_snapshot(calendar_id=calendar_id_arg, day=day, tz=tz) - - # Deduplicate by event ID β€” first occurrence wins. - # GCalEvent.id is a required str field, never None. - seen: set[str] = set() - unique_events = [] - for event in snapshot.response.events: - if event.id not in seen: - seen.add(event.id) - unique_events.append(event) - - if len(unique_events) == len(snapshot.response.events): - return snapshot # no duplicates β€” return as-is - - deduped_response = GCalEventsResponse( - events=unique_events, totalCount=len(unique_events) - ) - immovables = self._immovables_from_response( - response=deduped_response, day=day, tz=tz - ) - return CalendarDaySnapshot(response=deduped_response, immovables=immovables) - - async def list_day_immovables( - self, - *, - calendar_id: str, - day: date, - tz: ZoneInfo, - diagnostics: dict[str, Any] | None = None, - ) -> list[dict[str, str]]: - """Fetch a day's immovables from the MCP calendar server.""" - snapshot = await self.list_day_snapshot( - calendar_id=calendar_id, - day=day, - tz=tz, - diagnostics=diagnostics, - ) - return snapshot.immovables - - async def close(self) -> None: - """Close the underlying MCP workbench when supported.""" - await self._workbench.stop() diff --git a/src/fateforger/agents/timeboxing/messages.py b/src/fateforger/agents/timeboxing/messages.py deleted file mode 100644 index fc9c1182..00000000 --- a/src/fateforger/agents/timeboxing/messages.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Typed messages for the timeboxing workflow.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import datetime -from typing import Any, Dict, List, Literal, Optional - -from .actions import TimeboxAction -from .preferences import Constraint -from .timebox import Timebox - - -@dataclass -class StartTimeboxing: - """Signal that a timeboxing session should begin inside a topic/thread.""" - - channel_id: str - thread_ts: str - user_id: str - user_input: str - intent_summary: str | None = None - context: Dict[str, Any] = field(default_factory=dict) - - -@dataclass -class TimeboxingCommitDate: - """Stage 0: user commits the date (before constraints/calendar hydration).""" - - channel_id: str - thread_ts: str - user_id: str - planned_date: str # YYYY-MM-DD - timezone: str # IANA TZ name - - -@dataclass -class TimeboxingUserReply: - """User feedback that should update the in-flight timeboxing session.""" - - channel_id: str - user_id: str - text: str - thread_ts: str - - -@dataclass -class TimeboxingConfirmSubmit: - """User confirmed calendar submission via Slack action button.""" - - channel_id: str - thread_ts: str - user_id: str - - -@dataclass -class TimeboxingCancelSubmit: - """User canceled pending calendar submission via Slack action button.""" - - channel_id: str - thread_ts: str - user_id: str - - -@dataclass -class TimeboxingUndoSubmit: - """User requested undo of the latest calendar sync via Slack action button.""" - - channel_id: str - thread_ts: str - user_id: str - - -@dataclass -class TimeboxingStageAction: - """User clicked a deterministic stage-control button in Slack.""" - - channel_id: str - thread_ts: str - user_id: str - action: Literal["proceed", "back", "redo", "cancel"] - - -@dataclass -class TimeboxingFinalResult: - """Final result emitted when a session completes or aborts.""" - - thread_ts: str - status: str - summary: str - payload: Optional[Dict[str, Any]] = None - - -@dataclass -class TimeboxPatchRecord: - """Snapshot of a timebox modification and its justification.""" - - created_at: datetime - user_message: str - constraint_ids: List[int] = field(default_factory=list) - constraint_names: List[str] = field(default_factory=list) - actions: List[TimeboxAction] = field(default_factory=list) - - -@dataclass -class TimeboxingUpdate: - """Structured output for downstream consumers (constraints + timebox + patches).""" - - thread_ts: str - channel_id: str - user_id: str - user_message: str - constraints: List[Constraint] = field(default_factory=list) - timebox: Optional[Timebox] = None - actions: List[TimeboxAction] = field(default_factory=list) - patch_history: List[TimeboxPatchRecord] = field(default_factory=list) - - -__all__ = [ - "StartTimeboxing", - "TimeboxingCommitDate", - "TimeboxingUserReply", - "TimeboxingConfirmSubmit", - "TimeboxingCancelSubmit", - "TimeboxingUndoSubmit", - "TimeboxingStageAction", - "TimeboxingFinalResult", - "TimeboxPatchRecord", - "TimeboxingUpdate", -] diff --git a/src/fateforger/agents/timeboxing/nlu.py b/src/fateforger/agents/timeboxing/nlu.py deleted file mode 100644 index a7460e4c..00000000 --- a/src/fateforger/agents/timeboxing/nlu.py +++ /dev/null @@ -1,312 +0,0 @@ -"""Structured multilingual interpretation helpers for timeboxing. - -This module replaces deterministic, English-only parsing for: -- planned date interpretation (e.g. "tomorrow", "next Monday", other languages) -- constraint intent + scope inference (session/profile/datespan) from natural language - -All interpretation uses structured LLM outputs (Pydantic) and avoids keyword/regex intent logic. -""" - -from __future__ import annotations - -import json -from typing import Literal, Optional - -from autogen_agentchat.agents import AssistantAgent -from autogen_ext.models.openai import OpenAIChatCompletionClient -from pydantic import BaseModel, Field, TypeAdapter - -from fateforger.agents.timeboxing.planning_aspects import ConstraintAspectClassification -from fateforger.agents.timeboxing.preferences import ConstraintBase - - -class PlannedDateResult(BaseModel): - """Structured result for interpreting a user's intended planning date.""" - - planned_date: Optional[str] = Field( - default=None, description="ISO date (YYYY-MM-DD) if confidently inferred" - ) - confidence: Optional[float] = Field( - default=None, ge=0.0, le=1.0, description="Confidence in planned_date" - ) - timezone: Optional[str] = Field( - default=None, description="IANA timezone if the user explicitly referenced one" - ) - language: Optional[str] = Field( - default=None, description="Optional BCP-47 language tag for telemetry" - ) - explanation: Optional[str] = Field( - default=None, description="Short debug explanation; not shown to the user" - ) - - -ConstraintScopeLiteral = Literal["session", "profile", "datespan"] - - -class ConstraintInterpretation(BaseModel): - """Interpretation result for constraint extraction + scope inference.""" - - should_extract: bool = Field( - description="True only if the user explicitly stated scheduling constraints/preferences" - ) - scope: ConstraintScopeLiteral = Field( - description="session (this thread), profile (durable), or datespan (bounded period)" - ) - start_date: Optional[str] = Field( - default=None, description="ISO date (YYYY-MM-DD) when scope=datespan" - ) - end_date: Optional[str] = Field( - default=None, description="ISO date (YYYY-MM-DD) when scope=datespan" - ) - constraints: list[ConstraintBase] = Field(default_factory=list) - language: Optional[str] = Field(default=None) - explanation: Optional[str] = Field(default=None) - - -class MemoryReviewDecision(BaseModel): - """Structured routing decision for in-thread memory review turns.""" - - action: Literal["memory_review", "none"] = Field( - description=( - "memory_review when the user asks to inspect remembered " - "constraints/preferences; otherwise none." - ) - ) - text_query: Optional[str] = Field( - default=None, - description=( - "Optional focused search text for memory review. Null to list all " - "currently relevant remembered constraints." - ), - ) - statuses: list[str] = Field( - default_factory=list, - description="Optional status filters (locked, proposed, declined).", - ) - scopes: list[str] = Field( - default_factory=list, - description="Optional scope filters (session, profile, datespan).", - ) - necessities: list[str] = Field( - default_factory=list, - description="Optional necessity filters (must, should, prefer).", - ) - tags: list[str] = Field( - default_factory=list, - description="Optional topic tags for narrowing results.", - ) - limit: int = Field( - default=20, - ge=1, - le=50, - description="Maximum number of remembered constraints to list.", - ) - explanation: Optional[str] = Field( - default=None, description="Short debug-only reasoning." - ) - - -PLANNED_DATE_INTERPRETER_PROMPT = """ -You are Schedular, interpreting which DATE the user wants to plan. - -Task -- Interpret the user message in ANY language. -- Output STRICT JSON matching PlannedDateResult. - -Rules -- Only set planned_date when the user explicitly indicates a date (relative or absolute). -- If uncertain, set planned_date=null and confidence<=0.4. -- Use ISO date format YYYY-MM-DD. -- Use the provided timezone for resolving relative dates unless the user explicitly mentions a different timezone. -- Do not invent dates. -""".strip() - - -#: Deliberately absent: there is no canonical list of a person's daily slots. -#: -#: There was one -- fourteen hand-typed slugs. Measured against the anchors the -#: memory server had actually learned from Hugo: ten of the fourteen named -#: things he has never once said (`dog_walk`, `music_making`, `pre_gym_meal`, -#: `sleep_target`, `work_window`), while fourteen things he does do were absent -#: (`fika`, `market_visits`, `nature_reservation`, `prep_food`, `admin`, -#: `finance`). Wrong in both directions, which is what a hand-typed model of -#: somebody else's life is always going to be. -#: -#: The right vocabulary already exists and is discovered rather than declared: -#: `anchors` in the memory corpus, 29 of them, grown from what the user said. -#: A slot is an anchor, and an anchor is a row. - -CONSTRAINT_INTERPRETER_PROMPT = """ -You are Schedular, interpreting whether a message contains explicit scheduling constraints/preferences and what scope the user intended. - -Task -- Interpret the user message in ANY language. -- Output STRICT JSON matching ConstraintInterpretation. - -Definitions -- should_extract: true only if the user explicitly states a scheduling constraint or preference as THEIR own. -- scope: - - session: applies only to this timeboxing session / thread - - profile: durable preference ONLY when the user explicitly indicates permanence (e.g. "always", "never", "from now on", "save this permanently"), in any language - - datespan: applies to a bounded period the user indicates ("this week", "next 2 weeks", date range) in any language - -Rules -- Never extract from generic "start timeboxing" messages, greetings, or meta-chat about the bot. -- Extract only what the user stated; do not infer missing times or add new rules. -- Default to scope=session unless the user explicitly indicates durable or bounded-period intent. -- Return constraints=[] if should_extract=false. -- If scope=datespan, include start_date/end_date as ISO dates if the user provided enough info; otherwise keep them null. - -Aspect classification (REQUIRED for every extracted constraint) -For every constraint in the constraints list, always set hints.aspect_classification to a JSON -object with the following fields: -- aspect_id (string): stable slug for the planning aspect, lower_snake_case, e.g. "sleep_window", - "gym_training", "field_hockey", "morning_commute", "dog_walk". Reuse the same slug across turns - for the same life-area. -- aspect_label (string): human-readable display name, e.g. "Sleep window", "Gym training". -- category (string): open category string. Use one of these well-known values when applicable: - sleep | work | exercise | family | pet | social | transport | hobby | nutrition | health | learning - Use any other meaningful string for categories not in this list. -- frame_slot (string or null): use this to anchor the constraint to a fixed daily slot, - as a concise lowercase_snake_case slug naming the recurring activity itself - ("morning_ritual", "evening_shutdown_ritual", "saxophone_practice"). - Reuse the same slug across turns for the same slot; the vocabulary is the - user's own habits, not a fixed list. - Never leave frame_slot null for a recurring daily routine or lifestyle anchor. - Null is only correct for one-off or purely conditional constraints. -- is_startup_prefetch (bool): true when this constraint anchors the day and should be loaded at - session start BEFORE the user has said anything (sleep schedule, work window, primary transport). - False for activity preferences that are only needed during planning. -- schedule_start (string or null): HH:MM if a concrete start time was stated. Null otherwise. -- schedule_end (string or null): HH:MM if a concrete end time was stated. Null otherwise. -- duration_min (integer or null): typical or minimum duration in minutes if stated. Null otherwise. -- is_conditional (bool): true when this constraint only applies given another aspect being present - or absent (e.g. "only if I don't have an evening meeting"). -- conditional_on_absent (list[string]): aspect_id values that must be absent for this to apply. -- conditional_on_present (list[string]): aspect_id values that must be present for this to apply. -- excludes_aspect_ids (list[string]): aspect_id values that should not be scheduled when this - aspect is confirmed (e.g. a long commute day excludes the gym). - -Example hints for a sleep constraint: - "hints": { - "aspect_classification": { - "aspect_id": "sleep_window", "aspect_label": "Sleep window", - "category": "sleep", "frame_slot": "sleep_target", - "is_startup_prefetch": true, - "schedule_start": "23:00", "schedule_end": "07:00", - "duration_min": 480, "is_conditional": false, - "conditional_on_absent": [], "conditional_on_present": [], - "excludes_aspect_ids": [] - } - } - -Example hints for a conditional exercise constraint: - "hints": { - "aspect_classification": { - "aspect_id": "gym_training", "aspect_label": "Gym training", - "category": "exercise", "frame_slot": "gym", - "is_startup_prefetch": false, - "schedule_start": "07:00", "schedule_end": "08:30", - "duration_min": 90, "is_conditional": true, - "conditional_on_absent": ["late_meeting"], - "conditional_on_present": [], - "excludes_aspect_ids": [] - } - } -""".strip() - - -MEMORY_REVIEW_ROUTER_PROMPT = """ -You are Schedular, deciding whether a user reply should trigger memory review. - -Task -- Interpret the user message in ANY language. -- Output STRICT JSON matching MemoryReviewDecision. - -Choose action="memory_review" when the user asks to inspect remembered -constraints/preferences/defaults/assumptions, for example: -- "which memories are you using?" -- "show my remembered constraints" -- "what defaults are active right now?" -- "show scope/source/status" - -Choose action="none" when the user is primarily asking for schedule edits, -stage progression, or unrelated questions. - -Rules -- If the message mixes schedule edits with memory review, choose action="none". -- Use optional filters only when explicitly implied by the user. -- Keep text_query concise and useful. -""".strip() - - -def _constraint_interpreter_prompt_with_schema() -> str: - """Return schema-augmented prompt for robust text-mode JSON parsing.""" - schema_json = json.dumps( - TypeAdapter(ConstraintInterpretation).json_schema(), - ensure_ascii=False, - sort_keys=True, - indent=2, - ) - return ( - CONSTRAINT_INTERPRETER_PROMPT - + "\n\nReturn ONLY valid JSON matching this schema.\n" - + f"ConstraintInterpretation JSON Schema:\n```json\n{schema_json}\n```" - ) - - -def build_planned_date_interpreter( - *, model_client: OpenAIChatCompletionClient -) -> AssistantAgent: - """Build the planned-date interpreter agent.""" - return AssistantAgent( - name="PlanningDateInterpreter", - model_client=model_client, - output_content_type=PlannedDateResult, - system_message=PLANNED_DATE_INTERPRETER_PROMPT, - reflect_on_tool_use=False, - max_tool_iterations=1, - ) - - -def build_constraint_interpreter( - *, model_client: OpenAIChatCompletionClient -) -> AssistantAgent: - """Build the constraint interpreter agent.""" - return AssistantAgent( - name="ConstraintInterpreter", - model_client=model_client, - # ConstraintInterpretation includes open dict fields through nested models; - # OpenAI strict response_format rejects that schema. Keep typed validation - # by parsing JSON text with Pydantic instead. - output_content_type=None, - system_message=_constraint_interpreter_prompt_with_schema(), - reflect_on_tool_use=False, - max_tool_iterations=1, - ) - - -def build_memory_review_router( - *, model_client: OpenAIChatCompletionClient -) -> AssistantAgent: - """Build the in-thread memory review routing agent.""" - return AssistantAgent( - name="MemoryReviewRouter", - model_client=model_client, - output_content_type=MemoryReviewDecision, - system_message=MEMORY_REVIEW_ROUTER_PROMPT, - reflect_on_tool_use=False, - max_tool_iterations=1, - ) - - -__all__ = [ - "ConstraintAspectClassification", - "ConstraintInterpretation", - "ConstraintScopeLiteral", - "MemoryReviewDecision", - "PlannedDateResult", - "build_constraint_interpreter", - "build_memory_review_router", - "build_planned_date_interpreter", -] diff --git a/src/fateforger/agents/timeboxing/nodes/AGENTS.md b/src/fateforger/agents/timeboxing/nodes/AGENTS.md deleted file mode 100644 index 1efdd34d..00000000 --- a/src/fateforger/agents/timeboxing/nodes/AGENTS.md +++ /dev/null @@ -1,50 +0,0 @@ -# Timeboxing Nodes β€” Agent Notes - -**Scope:** Operational rules for `nodes/`. For file index and data flow, see `README.md` in this folder. - -## Design Rules - -- Each node is a `BaseChatAgent` with `on_messages()` / `on_messages_stream()`. -- Nodes are **stateless between turns**; all mutable state lives on the shared `Session` object passed at construction. -- Nodes must not import or call MCP clients, Slack SDK, or database layers. Tool IO is the coordinator's job. -- Nodes should not catch and swallow exceptions; let them propagate so the coordinator can handle them. -- One user-facing message per Slack turn; `PresenterNode` is the only node that produces user-visible output. -- No node may inspect LLM prose via keyword/substring/regex heuristics. Use typed outputs (`StageGateOutput`, `StageDecision`, etc.) and explicit session state only. - -## GraphFlow Safety - -- Every node must be registered in `flow_graph.py` via `DiGraphBuilder`; do not instantiate nodes outside the graph. -- Edge conditions in `flow_graph.py` are the single source of truth for stage transitions; do not hard-code transitions in nodes. -- If a node needs to signal "stay in current stage" vs "advance", it must do so via its return value (e.g., `StageGateOutput.advance`), not by mutating `session.stage` directly. - -## Sync Engine Awareness - -- `StageReviewCommitNode` provides final review output only; it does not introduce an extra submit-confirm gate. -- Undo remains available through Slack action handlers (`ff_timebox_undo_submit`) via orchestrator message handlers. -- `StageRefineNode` calls `TimeboxPatcher` then `apply_tb_ops()`, and then syncs the updated `TBPlan` through `CalendarSubmitter`. -- `StageSkeletonNode` is presentation-first: it produces the markdown overview and carries forward any pre-generated draft plan; it does not build the remote baseline snapshot. -- `StageRefineNode` is responsible for preparing missing `TBPlan`/baseline state before patch+sync. -- Presenter-attached stage controls must remain deterministic: - - default controls are `Back`/`Redo`/`Cancel`, with `Proceed` included only when the stage is ready and no pending local Refine undo snapshot exists. - - after a Stage 4 local update, the same control row should present `Undo last update` (via the `Redo` action path) instead of `Proceed`. - - readiness checks happen when `Proceed` is clicked. -- Nodes must never call `sync_engine.py` functions directly; always go through `CalendarSubmitter`. - -## Stage Boundary Rules (Hard) - -- `StageSkeletonNode` (Stage 3): - - Must publish markdown overview via presenter markdown blocks. - - Must not require or emit a validated `Timebox` as Stage 3 output. - - Can carry `TBPlan` draft state forward for Stage 4 preparation. -- `StageRefineNode` (Stage 4): - - Must patch from `TBPlan` through `TimeboxPatcher` retry loop. - - Must pass validator failures back through retry context (no single-shot conversion fallback). - - Must materialize `Timebox` only from successful patch-loop validation output. - -## Adding a New Node - -1. Create the class extending `_StageNodeBase` (for stage nodes) or `BaseChatAgent` (for infra nodes). -2. Register in `flow_graph.py` with `DiGraphBuilder`. -3. Add edge conditions for entry/exit. -4. Add unit tests in `tests/unit/` following existing `test_timeboxing_graphflow_state_machine.py` patterns. -5. Update this folder's `README.md` with the new node. diff --git a/src/fateforger/agents/timeboxing/nodes/README.md b/src/fateforger/agents/timeboxing/nodes/README.md deleted file mode 100644 index fe7387af..00000000 --- a/src/fateforger/agents/timeboxing/nodes/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Timeboxing Nodes - -GraphFlow node agents that implement the timeboxing stage machine. Each node is a `BaseChatAgent` consumed by `flow_graph.py`. - -## File Index - -| File | Contents | -|------|----------| -| `nodes.py` | All node classes (see below) | -| `__init__.py` | Re-exports node classes | - -## Node Classes - -### Infrastructure Nodes - -| Node | Stage | Responsibility | -|------|-------|---------------| -| `TurnInitNode` | all | Receives the latest user message, stashes it on Session, produces the routing input for DecisionNode. | -| `DecisionNode` | all | Reads current `session.stage` and decides whether to advance, stay, or branch. Emits the stage label consumed by `TransitionNode`. | -| `TransitionNode` | all | Pure router: maps the decision label to the correct stage node. No LLM call. | -| `PresenterNode` | all | Formats stage output into Slack Block Kit and surfaces deterministic stage controls (Proceed/Back/Redo/Cancel) via orchestrator block attachment. One user-facing message per Slack turn. | - -### Stage Nodes (all extend `_StageNodeBase`) - -| Node | Stage | Responsibility | -|------|-------|---------------| -| `StageCollectConstraintsNode` | 1 | Builds constraint context (immovables + Notion + session constraints), calls the Stage 1 LLM via `stage_gating.py`, updates `session.frame_facts`. | -| `StageCaptureInputsNode` | 2 | Builds input context (frame_facts + user tasks/priorities), calls the Stage 2 LLM, updates `session.input_facts`, and queues skeleton pre-generation when context is sufficient. | -| `StageSkeletonNode` | 3 | Uses pre-generated skeleton when available; otherwise drafts synchronously. Produces markdown overview rendered via Slack `markdown` block and carries the prepared draft plan forward, but does not build sync baselines. | -| `StageRefineNode` | 4 | Prepares `TBPlan` + remote baseline if missing, then delegates execution to prompt-guided tool orchestration (`timebox_patch_and_sync` as patch-critical primary action, optional background memory update). Appends explicit calendar changed/unchanged sync feedback. | -| `StageReviewCommitNode` | 5 | Presents final plan summary. If user sends corrections, `TransitionNode` routes the same turn back to `StageRefineNode` so patching runs before another review. Undo remains available through Slack action routing (`ff_timebox_undo_submit`). | - -### Base Class - -`_StageNodeBase` provides common lifecycle: -1. Build typed context from Session fields. -2. Call the stage-specific LLM (via `stage_gating.py` prompt templates). -3. Parse `StageGateOutput` and update Session. -4. Cache `last_gate_output` for downstream nodes. - -## Data Flow - -``` -User message - -> TurnInitNode (stash on Session) - -> DecisionNode (read stage, decide) - -> TransitionNode (route) - -> Stage*Node (LLM call, Session update) - -> PresenterNode (Block Kit render -> Slack) -``` - -## Related - -- Parent: `../README.md` (timeboxing module index) -- `../flow_graph.py`: builds the GraphFlow DAG that wires these nodes together -- `../stage_gating.py`: stage enum + LLM prompt templates consumed by stage nodes -- `../contracts.py`: typed stage context models consumed by stage nodes diff --git a/src/fateforger/agents/timeboxing/nodes/__init__.py b/src/fateforger/agents/timeboxing/nodes/__init__.py deleted file mode 100644 index e8c45d1b..00000000 --- a/src/fateforger/agents/timeboxing/nodes/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -"""GraphFlow node agents for the timeboxing workflow.""" - -from .nodes import ( - DecisionNode, - PresenterNode, - StageCaptureInputsNode, - StageCollectConstraintsNode, - StageRefineNode, - StageReviewCommitNode, - StageSkeletonNode, - TransitionNode, - TurnInitNode, -) - -__all__ = [ - "DecisionNode", - "PresenterNode", - "StageCaptureInputsNode", - "StageCollectConstraintsNode", - "StageRefineNode", - "StageReviewCommitNode", - "StageSkeletonNode", - "TransitionNode", - "TurnInitNode", -] - diff --git a/src/fateforger/agents/timeboxing/nodes/nodes.py b/src/fateforger/agents/timeboxing/nodes/nodes.py deleted file mode 100644 index 1e5f7f0d..00000000 --- a/src/fateforger/agents/timeboxing/nodes/nodes.py +++ /dev/null @@ -1,806 +0,0 @@ -"""GraphFlow node agent implementations for the timeboxing stage machine. - -These nodes are lightweight orchestration agents that: -- mutate the in-memory `Session` -- call existing stage helpers on `TimeboxingFlowAgent` -- emit small control messages (StructuredMessage) or the final user-facing TextMessage - -The graph itself is built in `src/fateforger/agents/timeboxing/flow_graph.py`. -""" - -from __future__ import annotations - -from dataclasses import dataclass -import logging -from typing import TYPE_CHECKING, Any, Optional, Sequence - -from autogen_agentchat.agents._base_chat_agent import BaseChatAgent -from autogen_agentchat.base._chat_agent import Response -from autogen_agentchat.messages import BaseChatMessage, StructuredMessage, TextMessage -from autogen_core import CancellationToken -from pydantic import BaseModel - -from fateforger.agents.shared.handoff_policy import ( - HandoffIntent, - HandoffPolicy, - HandoffRoute, -) -from fateforger.agents.timeboxing.constants import TIMEBOXING_TIMEOUTS -from fateforger.agents.timeboxing.stage_gating import ( - StageDecision, - StageGateOutput, - TimeboxingStage, -) - -if TYPE_CHECKING: # pragma: no cover - from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent - -logger = logging.getLogger(__name__) - - -class FlowSignal(BaseModel): - """Internal routing signal for GraphFlow nodes.""" - - kind: str - note: Optional[str] = None - - -def _latest_user_text(messages: Sequence[BaseChatMessage]) -> str: - """Return the newest user text from a message batch.""" - for msg in reversed(messages): - if isinstance(msg, TextMessage) and msg.source == "user": - return msg.content - # Fallback: some runtimes may pass task as a plain TextMessage with other source. - for msg in reversed(messages): - if isinstance(msg, TextMessage): - return msg.content - return "" - - -@dataclass(slots=True) -class TurnContext: - """Per-turn state shared across nodes (in-memory only).""" - - user_text: str = "" - decision: StageDecision | None = None - extraction_task: Any | None = None - - -class TurnInitNode(BaseChatAgent): - """Initialize per-turn context and kick off background work.""" - - def __init__( - self, *, orchestrator: "TimeboxingFlowAgent", session: "Session" - ) -> None: - super().__init__(name="TurnInitNode", description="Timeboxing turn initializer") - self._orchestrator = orchestrator - self._session = session - self.turn = TurnContext() - - @property - def produced_message_types(self) -> Sequence[type[BaseChatMessage]]: - return (StructuredMessage,) - - async def on_messages( - self, messages: Sequence[BaseChatMessage], cancellation_token: CancellationToken - ) -> Response: - user_text = _latest_user_text(messages) - self.turn = TurnContext(user_text=user_text) - self._session.last_user_message = user_text - - await self._orchestrator._ensure_calendar_immovables( - self._session - ) # noqa: SLF001 - self.turn.extraction_task = ( - self._orchestrator._queue_constraint_extraction( # noqa: SLF001 - session=self._session, - text=user_text, - reason="graphflow_turn", - is_initial=False, - ) - ) - self._session.last_extraction_task = self.turn.extraction_task - return Response( - chat_message=StructuredMessage( - source=self.name, - content=FlowSignal(kind="turn_init"), - ) - ) - - async def on_reset(self, cancellation_token: CancellationToken) -> None: - self.turn = TurnContext() - - -class DecisionNode(BaseChatAgent): - """Decide whether to proceed/back/cancel/redo based on the user reply.""" - - def __init__( - self, - *, - orchestrator: "TimeboxingFlowAgent", - session: "Session", - turn_init: TurnInitNode, - ) -> None: - super().__init__(name="DecisionNode", description="Timeboxing decision node") - self._orchestrator = orchestrator - self._session = session - self._turn_init = turn_init - - @property - def produced_message_types(self) -> Sequence[type[BaseChatMessage]]: - return (StructuredMessage,) - - async def on_messages( - self, messages: Sequence[BaseChatMessage], cancellation_token: CancellationToken - ) -> Response: - user_text = self._turn_init.turn.user_text - match ( - self._session.force_stage_rerun, - user_text.strip(), - self._session.stage_ready, - ): - case (True, _, _): - self._session.force_stage_rerun = False - decision = StageDecision(action="redo", note="stage_action_rerun") - case (_, "", _): - decision = StageDecision(action="provide_info") - case _: - decision = await self._orchestrator._decide_next_action( # noqa: SLF001 - self._session, user_message=user_text - ) - match ( - self._session.stage_ready, - bool(user_text.strip()), - decision.action, - ): - case (False, True, "proceed"): - # Preserve user details for the stage gate when "proceed" - # is selected before the current stage is ready. - decision = StageDecision( - action="provide_info", note="stage_not_ready_user_detail" - ) - case _: - pass - self._turn_init.turn.decision = decision - return Response( - chat_message=StructuredMessage(source=self.name, content=decision) - ) - - async def on_reset(self, cancellation_token: CancellationToken) -> None: - return None - - -class TransitionNode(BaseChatAgent): - """Apply the decision to session.stage and derive the stage runner user_message.""" - - def __init__( - self, - *, - orchestrator: "TimeboxingFlowAgent", - session: "Session", - turn_init: TurnInitNode, - ) -> None: - super().__init__( - name="TransitionNode", description="Timeboxing transition node" - ) - self._orchestrator = orchestrator - self._session = session - self._turn_init = turn_init - self.stage_user_message: str = "" - - @property - def produced_message_types(self) -> Sequence[type[BaseChatMessage]]: - return (StructuredMessage,) - - async def on_messages( - self, messages: Sequence[BaseChatMessage], cancellation_token: CancellationToken - ) -> Response: - decision = self._turn_init.turn.decision - user_text = self._turn_init.turn.user_text - self.stage_user_message = user_text - self._session.skip_stage_execution = False - - if decision is None: - return Response( - chat_message=StructuredMessage( - source=self.name, - content=FlowSignal(kind="transition", note="no-decision"), - ) - ) - - match decision.action: - case "cancel": - self._session.completed = True - self._session.thread_state = "canceled" - self._session.last_response = "Okayβ€”stopping this timeboxing session." - signal = FlowSignal(kind="transition", note="canceled") - case "back": - target = ( - decision.target_stage - or self._orchestrator._previous_stage( # noqa: SLF001 - self._session.stage - ) - ) - await self._orchestrator._advance_stage( - self._session, next_stage=target - ) # noqa: SLF001 - signal = FlowSignal(kind="transition", note="back") - case "proceed": - await self._orchestrator._proceed(self._session) # noqa: SLF001 - self.stage_user_message = "" - signal = FlowSignal(kind="transition", note="proceed") - case "assist": - memory_reply = await self._orchestrator._maybe_handle_memory_review_turn( # noqa: SLF001 - session=self._session, - user_message=user_text, - ) - if memory_reply is not None: - self._session.last_response = str(memory_reply.content or "").strip() - self._session.skip_stage_execution = True - self.stage_user_message = "" - signal = FlowSignal(kind="transition", note="memory_review") - return Response( - chat_message=StructuredMessage( - source=self.name, - content=signal, - ) - ) - assist_policy = HandoffPolicy(allowed_targets={"tasks_agent"}) - route = assist_policy.resolve( - HandoffIntent( - action=decision.action, - target=decision.assist_target, - confidence=decision.assist_confidence, - ) - ) - if route != HandoffRoute.HANDOFF: - signal = FlowSignal(kind="transition", note="rerun") - return Response( - chat_message=StructuredMessage( - source=self.name, - content=signal, - ) - ) - assist_reply = await self._orchestrator._run_assist_turn( # noqa: SLF001 - session=self._session, - user_message=user_text, - note=decision.note, - assist_target=decision.assist_target, - ) - if assist_reply: - self._session.last_response = assist_reply - self._session.skip_stage_execution = True - self.stage_user_message = "" - signal = FlowSignal(kind="transition", note="assist") - else: - signal = FlowSignal(kind="transition", note="rerun") - case "provide_info": - # Honor explicit typed reroutes from the decision model first. - target_stage = decision.target_stage - if ( - isinstance(target_stage, TimeboxingStage) - and target_stage != self._session.stage - ): - await self._orchestrator._advance_stage( # noqa: SLF001 - self._session, - next_stage=target_stage, - ) - # Backward-compatible defaults for older decisions that omit target_stage. - elif self._session.stage in ( - TimeboxingStage.REVIEW_COMMIT, - TimeboxingStage.SKELETON, - ): - await self._orchestrator._advance_stage( # noqa: SLF001 - self._session, - next_stage=TimeboxingStage.REFINE, - ) - signal = FlowSignal(kind="transition", note="rerun") - case _: - signal = FlowSignal(kind="transition", note="rerun") - - return Response(chat_message=StructuredMessage(source=self.name, content=signal)) - - async def on_reset(self, cancellation_token: CancellationToken) -> None: - self.stage_user_message = "" - - -class _StageNodeBase(BaseChatAgent): - """Base class for stage nodes that update Session and cache the last gate output.""" - - def __init__( - self, - *, - name: str, - orchestrator: "TimeboxingFlowAgent", - session: "Session", - transition: TransitionNode, - ) -> None: - super().__init__(name=name, description=f"Run stage {name}") - self._orchestrator = orchestrator - self._session = session - self._transition = transition - self.last_gate: Any | None = None - - @property - def produced_message_types(self) -> Sequence[type[BaseChatMessage]]: - return (StructuredMessage,) - - async def on_reset(self, cancellation_token: CancellationToken) -> None: - self.last_gate = None - - -class StageCollectConstraintsNode(_StageNodeBase): - """Run CollectConstraints stage gate and update frame_facts.""" - - def __init__( - self, - *, - orchestrator: "TimeboxingFlowAgent", - session: "Session", - transition: TransitionNode, - ) -> None: - super().__init__( - name="StageCollectConstraintsNode", - orchestrator=orchestrator, - session=session, - transition=transition, - ) - - async def on_messages( - self, messages: Sequence[BaseChatMessage], cancellation_token: CancellationToken - ) -> Response: - user_message = self._transition.stage_user_message - gate = await self._orchestrator._run_stage_gate( # noqa: SLF001 - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - user_message=user_message, - context=self._orchestrator._build_collect_constraints_context( # noqa: SLF001 - self._session, user_message=user_message - ), - ) - self._session.frame_facts.update(gate.facts or {}) - if user_message.strip(): - await self._orchestrator._refresh_collect_constraints_durable( # noqa: SLF001 - self._session, - reason="collect_user_reply", - ) - gate = self._orchestrator._normalize_collect_constraints_gate( # noqa: SLF001 - session=self._session, - gate=gate, - user_message=user_message, - ) - self._session.stage_ready = gate.ready - self._session.stage_missing = list(gate.missing or []) - self._session.stage_question = gate.question - self._session.frame_facts.update(gate.facts or {}) - self.last_gate = gate - return Response(chat_message=StructuredMessage(source=self.name, content=gate)) - - -class StageCaptureInputsNode(_StageNodeBase): - """Run CaptureInputs stage gate and update input_facts.""" - - def __init__( - self, - *, - orchestrator: "TimeboxingFlowAgent", - session: "Session", - transition: TransitionNode, - ) -> None: - super().__init__( - name="StageCaptureInputsNode", - orchestrator=orchestrator, - session=session, - transition=transition, - ) - - async def on_messages( - self, messages: Sequence[BaseChatMessage], cancellation_token: CancellationToken - ) -> Response: - user_message = self._transition.stage_user_message - await self._orchestrator._await_pending_constraint_extractions( # noqa: SLF001 - self._session - ) - gate = await self._orchestrator._run_stage_gate( # noqa: SLF001 - stage=TimeboxingStage.CAPTURE_INPUTS, - user_message=user_message, - context=self._orchestrator._build_capture_inputs_context( # noqa: SLF001 - self._session, user_message=user_message - ), - ) - self._session.stage_ready = gate.ready - self._session.stage_missing = list(gate.missing or []) - self._session.stage_question = gate.question - self._session.input_facts.update(gate.facts or {}) - self._orchestrator._queue_skeleton_pre_generation(self._session) # noqa: SLF001 - self.last_gate = gate - return Response(chat_message=StructuredMessage(source=self.name, content=gate)) - - -class StageSkeletonNode(_StageNodeBase): - """Draft a skeleton timebox and summarize it.""" - - def __init__( - self, - *, - orchestrator: "TimeboxingFlowAgent", - session: "Session", - transition: TransitionNode, - ) -> None: - super().__init__( - name="StageSkeletonNode", - orchestrator=orchestrator, - session=session, - transition=transition, - ) - - async def on_messages( - self, messages: Sequence[BaseChatMessage], cancellation_token: CancellationToken - ) -> Response: - if not self._session.frame_facts and not self._session.input_facts: - self._session.last_response = "Stage 3/5 (Skeleton)\nMissing prior inputs. Please go back to earlier stages." - self.last_gate = None - return Response( - chat_message=StructuredMessage( - source=self.name, - content=FlowSignal(kind="stage", note="missing-priors"), - ) - ) - try: - ( - self._session.timebox, - self._session.skeleton_overview_markdown, - self._session.tb_plan, - ) = await self._orchestrator._consume_pre_generated_skeleton( - self._session - ) # noqa: SLF001 - except Exception as exc: - logger.warning("Skeleton drafting failed: %s", exc, exc_info=True) - gate = StageGateOutput( - stage_id=TimeboxingStage.SKELETON, - ready=False, - summary=[ - "I couldn't draft the Stage 3 overview yet.", - f"Drafting error: {str(exc) or type(exc).__name__}", - ], - missing=["A valid draft plan from current inputs/constraints"], - question="Please click Redo to retry Stage 3 drafting.", - facts={}, - ) - self._session.stage_ready = False - self._session.stage_missing = list(gate.missing or []) - self._session.stage_question = gate.question - self.last_gate = gate - return Response(chat_message=StructuredMessage(source=self.name, content=gate)) - # Stage 3 is presentation-only. Keep Timebox materialization for Stage 4. - self._session.timebox = None - # Stage 3 defers sync baseline preparation to Stage 4. - self._session.base_snapshot = None - markdown = ( - self._session.skeleton_overview_markdown - or "## Day Overview\n- No skeleton overview was generated." - ) - self._session.stage_ready = True - self._session.stage_missing = [] - self._session.stage_question = "Reply with adjustments, or tell me to proceed." - self._session.last_response = "Stage 3/5 (Skeleton)\nOverview ready below." - self._session.pending_presenter_blocks = ( - self._orchestrator._render_markdown_summary_blocks(text=markdown) # noqa: SLF001 - ) - self.last_gate = None - return Response( - chat_message=StructuredMessage( - source=self.name, - content=FlowSignal(kind="stage", note="skeleton-overview"), - ) - ) - - -class StageRefineNode(_StageNodeBase): - """Apply patch-based refinement and summarize the updated timebox.""" - - def __init__( - self, - *, - orchestrator: "TimeboxingFlowAgent", - session: "Session", - transition: TransitionNode, - ) -> None: - super().__init__( - name="StageRefineNode", - orchestrator=orchestrator, - session=session, - transition=transition, - ) - - async def on_messages( - self, messages: Sequence[BaseChatMessage], cancellation_token: CancellationToken - ) -> Response: - if self._session.timebox is None and self._session.tb_plan is None: - try: - drafted_timebox, markdown, drafted_plan = ( - await self._orchestrator._consume_pre_generated_skeleton( # noqa: SLF001 - self._session - ) - ) - self._session.timebox = drafted_timebox - self._session.tb_plan = drafted_plan - self._session.base_snapshot = None - if markdown and not self._session.skeleton_overview_markdown: - self._session.skeleton_overview_markdown = markdown - except Exception: - self._session.last_response = ( - "Stage 4/5 (Refine)\nNo draft timebox yet. Proceed from Skeleton first." - ) - self.last_gate = None - return Response( - chat_message=StructuredMessage( - source=self.name, - content=FlowSignal(kind="stage", note="missing-timebox"), - ) - ) - await self._orchestrator._ensure_calendar_immovables(self._session) # noqa: SLF001 - try: - preflight = self._orchestrator._ensure_refine_plan_state( # noqa: SLF001 - self._session - ) - except Exception as exc: - logger.warning("Refine preflight failed: %s", exc, exc_info=True) - gate = StageGateOutput( - stage_id=TimeboxingStage.REFINE, - ready=False, - summary=[ - "I couldn't prepare the editable Stage 4 plan yet.", - f"Preparation error: {str(exc) or type(exc).__name__}", - ], - missing=["A valid baseline plan and calendar snapshot"], - question=( - "Please click Redo to retry Stage 4 preparation, or share a simpler " - "adjustment request." - ), - facts={}, - ) - self._session.stage_ready = False - self._session.stage_missing = list(gate.missing or []) - self._session.stage_question = gate.question - self.last_gate = gate - return Response(chat_message=StructuredMessage(source=self.name, content=gate)) - user_message = self._transition.stage_user_message.strip() - if not user_message and not preflight.has_plan_issues: - try: - self._orchestrator._materialize_timebox_from_tb_plan( # noqa: SLF001 - self._session - ) - except Exception as exc: - preflight.plan_issues.append( - f"tb_plan_to_timebox: {str(exc) or type(exc).__name__}" - ) - if self._session.tb_plan is None: - gate = StageGateOutput( - stage_id=TimeboxingStage.REFINE, - ready=False, - summary=[ - "I couldn't prepare the Stage 4 plan state.", - "Editable TBPlan is missing after preflight.", - ], - missing=["A TBPlan seed for Stage 4 patching"], - question="Please click Redo to retry Stage 4 preparation.", - facts={}, - ) - self._session.stage_ready = False - self._session.stage_missing = list(gate.missing or []) - self._session.stage_question = gate.question - self.last_gate = gate - return Response(chat_message=StructuredMessage(source=self.name, content=gate)) - - try: - if not user_message: - user_message = ( - "Prepare the editable Stage 4 plan from the current draft. " - "Preserve intent and ordering, and repair only what is needed " - "for plan validity." - ) - if preflight.has_plan_issues: - issues = "\n".join(f"- {issue}" for issue in preflight.plan_issues) - repair_instruction = ( - "Repair the current plan first so it passes validation, then apply the " - "user refinement while preserving intent/order as much as possible.\n" - f"Preflight validation issues:\n{issues}" - ) - user_message = f"{user_message}\n\n{repair_instruction}" - patch_message = self._orchestrator._compose_patcher_message( # noqa: SLF001 - base_message=user_message, - session=self._session, - stage=TimeboxingStage.REFINE.value, - extra={ - "preflight_plan_issues": list(preflight.plan_issues), - "preflight_snapshot_issues": list(preflight.snapshot_issues), - "quality_snapshot": self._orchestrator._quality_snapshot_for_prompt( # noqa: SLF001 - self._session - ), - }, - ) - execution = await self._orchestrator._run_refine_tool_orchestration( # noqa: SLF001 - session=self._session, - patch_message=patch_message, - user_message=self._transition.stage_user_message or user_message, - ) - except Exception as exc: - logger.warning("Refine patch failed: %s", exc, exc_info=True) - gate = StageGateOutput( - stage_id=TimeboxingStage.REFINE, - ready=False, - summary=[ - "I couldn't apply that refinement yet.", - f"Latest patch error: {str(exc) or type(exc).__name__}", - ], - missing=["A valid non-overlapping event sequence"], - question=( - "Please adjust the request (or simplify it) and click Redo " - "to try again." - ), - facts={}, - ) - self._session.stage_ready = False - self._session.stage_missing = list(gate.missing or []) - self._session.stage_question = gate.question - self.last_gate = gate - return Response(chat_message=StructuredMessage(source=self.name, content=gate)) - remaining_budget_s = self._orchestrator._remaining_graph_turn_budget_s( # noqa: SLF001 - self._session - ) - if ( - remaining_budget_s is not None - and remaining_budget_s < TIMEBOXING_TIMEOUTS.refine_summary_min_budget_s - ): - self._orchestrator._session_debug( # noqa: SLF001 - self._session, - "refine_summary_fastpath", - remaining_budget_s=round(remaining_budget_s, 3), - threshold_s=TIMEBOXING_TIMEOUTS.refine_summary_min_budget_s, - ) - gate = self._orchestrator._build_refine_budget_fastpath_gate( # noqa: SLF001 - session=self._session - ) - else: - allow_quality = ( - remaining_budget_s is None - or remaining_budget_s >= TIMEBOXING_TIMEOUTS.refine_quality_min_budget_s - ) - if not allow_quality: - self._orchestrator._session_debug( # noqa: SLF001 - self._session, - "refine_quality_skipped_budget", - remaining_budget_s=round(remaining_budget_s or 0.0, 3), - threshold_s=TIMEBOXING_TIMEOUTS.refine_quality_min_budget_s, - ) - gate = await self._orchestrator._run_timebox_summary( # noqa: SLF001 - stage=TimeboxingStage.REFINE, - timebox=self._session.timebox, - session=self._session, - allow_quality_enrichment=allow_quality, - ) - if execution.calendar.note: - gate.summary.append(execution.calendar.note) - if execution.fallback_patch_used: - gate.summary.append( - "Patch execution used fallback tool routing for this turn." - ) - if execution.memory_operations: - gate.summary.append( - f"Memory operations: {', '.join(execution.memory_operations)}." - ) - self._session.stage_ready = True - self._session.stage_missing = [] - self._session.stage_question = gate.question - self.last_gate = gate - return Response(chat_message=StructuredMessage(source=self.name, content=gate)) - - -class StageReviewCommitNode(_StageNodeBase): - """Run the final review/commit stage.""" - - def __init__( - self, - *, - orchestrator: "TimeboxingFlowAgent", - session: "Session", - transition: TransitionNode, - ) -> None: - super().__init__( - name="StageReviewCommitNode", - orchestrator=orchestrator, - session=session, - transition=transition, - ) - - async def on_messages( - self, messages: Sequence[BaseChatMessage], cancellation_token: CancellationToken - ) -> Response: - if not self._session.timebox: - self._session.last_response = ( - "Stage 5/5 (ReviewCommit)\nNo draft timebox yet. Go back to Skeleton." - ) - self.last_gate = None - return Response( - chat_message=StructuredMessage( - source=self.name, - content=FlowSignal(kind="stage", note="missing-timebox"), - ) - ) - # Skip the LLM call because we auto-commit directly from this stage. - # This resolves the 30s Slack route timeout issue. - gate = StageGateOutput( - stage_id=TimeboxingStage.REVIEW_COMMIT, - ready=True, - summary=["Auto-committing your timebox..."], - question="Submitting...", - missing=[], - facts={}, - ) - self._session.pending_submit = True - self._session.stage_ready = True - self._session.stage_missing = [] - self._session.stage_question = gate.question - self.last_gate = gate - return Response(chat_message=StructuredMessage(source=self.name, content=gate)) - - -class PresenterNode(BaseChatAgent): - """Build the Slack-facing message from the updated session state.""" - - def __init__( - self, - *, - orchestrator: "TimeboxingFlowAgent", - session: "Session", - stages: dict[TimeboxingStage, _StageNodeBase], - ) -> None: - super().__init__(name="PresenterNode", description="Timeboxing presenter") - self._orchestrator = orchestrator - self._session = session - self._stages = stages - - @property - def produced_message_types(self) -> Sequence[type[BaseChatMessage]]: - return (TextMessage,) - - async def on_messages( - self, messages: Sequence[BaseChatMessage], cancellation_token: CancellationToken - ) -> Response: - if self._session.last_response: - content = self._session.last_response - self._session.last_response = None - self._session.skip_stage_execution = False - return Response(chat_message=TextMessage(content=content, source=self.name)) - - background_notes = self._orchestrator._collect_background_notes( - self._session - ) # noqa: SLF001 - stage_node = self._stages.get(self._session.stage) - gate = getattr(stage_node, "last_gate", None) if stage_node else None - if gate is None: - fallback = ( - f"Stage {self._session.stage.value}: ready={self._session.stage_ready}" - ) - return Response( - chat_message=TextMessage(content=fallback, source=self.name) - ) - - text = self._orchestrator._format_stage_message( # noqa: SLF001 - gate, - background_notes=background_notes, - constraints=self._session.active_constraints, - immovables=self._session.frame_facts.get("immovables"), - timebox=self._session.timebox, - ) - if self._session.pending_submit: - self._session.pending_presenter_blocks = ( - self._orchestrator._render_submit_prompt_blocks( - session=self._session, - text=text, - ) - ) - return Response(chat_message=TextMessage(content=text, source=self.name)) - - async def on_reset(self, cancellation_token: CancellationToken) -> None: - return None diff --git a/src/fateforger/agents/timeboxing/notebook_entrypoints.py b/src/fateforger/agents/timeboxing/notebook_entrypoints.py deleted file mode 100644 index 5e62d56b..00000000 --- a/src/fateforger/agents/timeboxing/notebook_entrypoints.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Notebook-oriented entrypoints for inspecting and exercising timeboxing flow. - -These helpers are intentionally thin wrappers around the real agent methods. -They are designed for interactive notebook use without duplicating core logic. -""" - -from __future__ import annotations - -import inspect -import time -from dataclasses import dataclass -from datetime import date -from typing import Any -from uuid import uuid4 - -from .agent import Session, TimeboxingFlowAgent -from .stage_gating import TimeboxingStage -from .tb_models import TBPlan - - -@dataclass(frozen=True) -class MethodLocation: - """Source location for a Stage 3-relevant method.""" - - name: str - file_path: str - line: int - - -@dataclass -class Stage3DraftTrace: - """Result of running Stage 3 drafting directly.""" - - markdown: str - tb_plan: TBPlan | None - stage: TimeboxingStage - stage_ready: bool - stage_missing: list[str] - stage_question: str | None - debug_log_path: str | None - - -@dataclass -class GraphTurnTrace: - """Result of running one GraphFlow turn.""" - - user_text: str - response_text: str - stage: TimeboxingStage - stage_ready: bool - stage_missing: list[str] - stage_question: str | None - skeleton_markdown: str | None - tb_plan: TBPlan | None - debug_log_path: str | None - - -def create_agent(*, name: str = "timeboxing_notebook") -> TimeboxingFlowAgent: - """Instantiate the real timeboxing agent for notebook use.""" - return TimeboxingFlowAgent(name=name) - - -def create_session( - *, - channel_id: str = "notebook", - user_id: str = "notebook-user", - thread_ts: str | None = None, - planned_date: str | None = None, - tz_name: str = "Europe/Amsterdam", - stage: TimeboxingStage = TimeboxingStage.SKELETON, -) -> Session: - """Create a session object suitable for direct Stage 3 experiments.""" - if thread_ts is None: - thread_ts = f"{int(time.time())}.{uuid4().hex[:8]}" - if planned_date is None: - planned_date = date.today().isoformat() - return Session( - thread_ts=thread_ts, - channel_id=channel_id, - user_id=user_id, - planned_date=planned_date, - tz_name=tz_name, - stage=stage, - session_key=f"{channel_id}:{thread_ts}", - ) - - -def stage3_method_locations() -> dict[str, MethodLocation]: - """Return source locations for core Stage 3 implementation entrypoints.""" - from .nodes.nodes import StageSkeletonNode - - methods: dict[str, Any] = { - "agent._run_skeleton_draft": TimeboxingFlowAgent._run_skeleton_draft, - "agent._run_skeleton_overview_markdown": TimeboxingFlowAgent._run_skeleton_overview_markdown, - "agent._build_skeleton_seed_plan": TimeboxingFlowAgent._build_skeleton_seed_plan, - "agent._consume_pre_generated_skeleton": TimeboxingFlowAgent._consume_pre_generated_skeleton, - "node.StageSkeletonNode.on_messages": StageSkeletonNode.on_messages, - } - out: dict[str, MethodLocation] = {} - for name, fn in methods.items(): - file_path = inspect.getsourcefile(fn) or "" - _, line = inspect.getsourcelines(fn) - out[name] = MethodLocation(name=name, file_path=file_path, line=line) - return out - - -def stage3_framework_report() -> dict[str, bool]: - """Report whether Stage 3 uses framework-native building blocks.""" - from .nodes.nodes import StageSkeletonNode - - draft_src = inspect.getsource(TimeboxingFlowAgent._run_skeleton_draft) - overview_src = inspect.getsource(TimeboxingFlowAgent._run_skeleton_overview_markdown) - node_src = inspect.getsource(StageSkeletonNode.on_messages) - - return { - "uses_autogen_assistant_for_markdown": "AssistantAgent(" in overview_src, - "uses_patcher_for_plan_draft": "_timebox_patcher.apply_patch(" in draft_src, - "stage3_presentation_first_node": "self._session.timebox = None" in node_src, - "stage3_slack_markdown_block_path": "_render_markdown_summary_blocks" in node_src, - "stage3_has_no_direct_timebox_validator_in_draft_call": "plan_validator=" not in draft_src, - } - - -def stage3_source_snippets() -> dict[str, str]: - """Return source snippets for direct notebook inspection.""" - from .nodes.nodes import StageSkeletonNode - - return { - "agent._run_skeleton_draft": inspect.getsource(TimeboxingFlowAgent._run_skeleton_draft), - "agent._run_skeleton_overview_markdown": inspect.getsource( - TimeboxingFlowAgent._run_skeleton_overview_markdown - ), - "agent._build_skeleton_seed_plan": inspect.getsource( - TimeboxingFlowAgent._build_skeleton_seed_plan - ), - "node.StageSkeletonNode.on_messages": inspect.getsource( - StageSkeletonNode.on_messages - ), - } - - -async def run_stage3_draft( - *, - agent: TimeboxingFlowAgent, - session: Session, -) -> Stage3DraftTrace: - """Run Stage 3 draft path directly (no duplicated stage logic).""" - _, markdown, tb_plan = await agent._run_skeleton_draft(session) - return Stage3DraftTrace( - markdown=markdown, - tb_plan=tb_plan, - stage=session.stage, - stage_ready=session.stage_ready, - stage_missing=list(session.stage_missing or []), - stage_question=session.stage_question, - debug_log_path=session.debug_log_path, - ) - - -async def run_graph_turn( - *, - agent: TimeboxingFlowAgent, - session: Session, - user_text: str, -) -> GraphTurnTrace: - """Run one real GraphFlow turn and return structured trace data.""" - reply = await agent._run_graph_turn(session=session, user_text=user_text) - return GraphTurnTrace( - user_text=user_text, - response_text=reply.content, - stage=session.stage, - stage_ready=session.stage_ready, - stage_missing=list(session.stage_missing or []), - stage_question=session.stage_question, - skeleton_markdown=session.skeleton_overview_markdown, - tb_plan=session.tb_plan, - debug_log_path=session.debug_log_path, - ) - - -__all__ = [ - "GraphTurnTrace", - "MethodLocation", - "Stage3DraftTrace", - "create_agent", - "create_session", - "run_graph_turn", - "run_stage3_draft", - "stage3_framework_report", - "stage3_method_locations", - "stage3_source_snippets", -] - diff --git a/src/fateforger/agents/timeboxing/notion_constraint_extractor.py b/src/fateforger/agents/timeboxing/notion_constraint_extractor.py deleted file mode 100644 index 244629aa..00000000 --- a/src/fateforger/agents/timeboxing/notion_constraint_extractor.py +++ /dev/null @@ -1,374 +0,0 @@ -# TODO(deprecate): This entire module is dead code. The Notion-MCP extraction path -# (_ensure_constraint_mcp_tools / NotionConstraintExtractor) is never reached at -# runtime. The live write path goes through _upsert_constraints_to_durable_store β†’ -# _build_durable_constraint_record β†’ DurableConstraintStore. Do not import -# this module from new code. Remove once the agent.py dead-code block is cleaned up. -from __future__ import annotations - -import json -from datetime import date, datetime -from typing import Any, Dict, List, Optional - -from autogen_agentchat.agents import AssistantAgent -from autogen_agentchat.tools import AgentTool -from autogen_core import CancellationToken -from autogen_ext.models.openai import OpenAIChatCompletionClient -from pydantic import BaseModel, Field - -from fateforger.agents.timeboxing.constants import TIMEBOXING_TIMEOUTS -from fateforger.debug.diag import with_timeout - - -class ConstraintWindow(BaseModel): - kind: str = Field(description="prefer|avoid") - start_time_local: str = Field(description="HH:MM") - end_time_local: str = Field(description="HH:MM") - - -class ScalarParams(BaseModel): - duration_min: Optional[int] = None - duration_max: Optional[int] = None - contiguity: Optional[str] = Field( - default=None, description="prefer|require|irrelevant" - ) - - -class ConstraintPayload(BaseModel): - rule_kind: str - scalar_params: ScalarParams = Field(default_factory=ScalarParams) - windows: List[ConstraintWindow] = Field(default_factory=list) - - -class ConstraintApplicability(BaseModel): - start_date: Optional[str] = Field(default=None, description="YYYY-MM-DD") - end_date: Optional[str] = Field(default=None, description="YYYY-MM-DD") - days_of_week: Optional[List[str]] = None # [MO,TU,...] - timezone: Optional[str] = None - recurrence: Optional[str] = None - - -class ConstraintLifecycle(BaseModel): - uid: Optional[str] = None - supersedes_uids: List[str] = Field(default_factory=list) - ttl_days: Optional[int] = None - - -class AspectClassificationPayload(BaseModel): - """Structured semantic metadata the LLM assigns to a constraint at extraction time. - - Stored as ``constraint_record.aspect_classification`` in the durable record and - forwarded into ``Constraint.hints["aspect_classification"]`` when the record is - loaded back from the constraint-memory store. Agent code that needs to know the - scheduling domain of a constraint MUST read this field instead of scanning names - or descriptions with keywords or regex. - """ - - aspect_id: str = Field( - description="Stable lower_snake_case slug for this planning aspect, e.g. sleep_window, gym_training" - ) - aspect_label: str = Field( - description="Human-readable display name, e.g. Sleep window" - ) - category: str = Field( - description="Open category string. Well-known values: sleep|work|exercise|family|pet|social|transport|hobby|nutrition|health|learning" - ) - frame_slot: Optional[str] = Field( - default=None, - description="Legacy slot: sleep_target or work_window. Null otherwise.", - ) - is_startup_prefetch: bool = Field( - default=False, - description="True when this anchors the day and must be loaded before the user speaks (sleep schedule, work window)", - ) - schedule_start: Optional[str] = Field(default=None, description="HH:MM if stated") - schedule_end: Optional[str] = Field(default=None, description="HH:MM if stated") - duration_min: Optional[int] = Field( - default=None, description="Duration in minutes if stated" - ) - is_conditional: bool = Field( - default=False, - description="True when only applies given another aspect being present/absent", - ) - conditional_on_absent: List[str] = Field( - default_factory=list, - description="aspect_id values that must be absent for this to apply", - ) - conditional_on_present: List[str] = Field( - default_factory=list, - description="aspect_id values that must be present for this to apply", - ) - excludes_aspect_ids: List[str] = Field( - default_factory=list, - description="aspect_id values excluded when this aspect is confirmed", - ) - - -class ExtractedConstraintRecord(BaseModel): - name: str - description: str - necessity: str = Field(description="must|should") - status: str = Field(description="proposed|locked") - source: str = Field(description="user|calendar|system|feedback") - confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0) - scope: str = Field(description="session|profile|datespan") - applicability: ConstraintApplicability = Field( - default_factory=ConstraintApplicability - ) - lifecycle: ConstraintLifecycle = Field(default_factory=ConstraintLifecycle) - payload: ConstraintPayload - aspect_classification: Optional[AspectClassificationPayload] = Field( - default=None, - description="Semantic aspect metadata required by agent code. Must be populated for every constraint.", - ) - - applies_stages: List[str] = Field(default_factory=list) - applies_event_types: List[str] = Field(default_factory=list) - topics: List[str] = Field(default_factory=list) - - -class ConstraintExtractionOutput(BaseModel): - constraint_record: ExtractedConstraintRecord - clarifying_question: Optional[str] = None - notes_for_page_body: Optional[str] = None - - -class ConstraintHandoff(BaseModel): - planned_date: date - timezone: str - stage_id: Optional[str] = None - user_utterance: str - triggering_suggestion: Optional[str] = None - impacted_event_types: List[str] = Field(default_factory=list) - suggested_tags: List[str] = Field(default_factory=list) - session_id: Optional[str] = None - decision_scope: Optional[str] = None - - -CONSTRAINT_EXTRACTOR_SYSTEM_PROMPT = """ -Role: Preference Constraint Librarian - -Goal: -Convert a user's natural-language preference/correction into ONE Notion-compatible constraint record -that a timeboxing agent can apply in future sessions without chat history. - -Input: -You receive a single JSON payload (ConstraintHandoff) that includes: -- planned_date, timezone, stage_id -- user_utterance (verbatim) -- triggering_suggestion (optional) -- impacted_event_types, suggested_tags, decision_scope (optional) - -Output: -Return ONLY a JSON object matching the provided schema (ConstraintExtractionOutput). -Do not include extra keys or prose. - -Operational rules: -- Prefer structured properties (fields) over page-body prose. -- Keep records MECE: governance vs applicability vs routing vs rule payload vs lifecycle. -- Default `status=proposed` unless the user explicitly confirms/locks it. -- Default `scope=profile` for "in general / usually / I prefer" statements; otherwise `session` or `datespan`. -- If ambiguity remains, choose conservative defaults and add a single `clarifying_question`. - -Tools: -- constraint_query_types(stage, event_types) -- constraint_query_constraints(filters, type_ids, tags, sort, limit) -- constraint_upsert_constraint(record, event) -- constraint_log_event(event) - -Allowed enums: -- necessity: must|should -- status: proposed|locked -- source: user|calendar|system|feedback -- scope: session|profile|datespan -- payload.rule_kind: prefer_window|avoid_window|fixed_bedtime|min_sleep|buffer|sequencing|capacity -- payload.scalar_params.contiguity: prefer|require|irrelevant -- applicability.days_of_week: MO|TU|WE|TH|FR|SA|SU -- applies_stages: CollectConstraints|CaptureInputs|Skeleton|Refine|ReviewCommit -- applies_event_types: M|C|DW|SW|H|R|BU|BG|PR -- windows[].kind: prefer|avoid - -Record guidelines: -- `name`: short label, human scannable. -- `description`: one sentence operational meaning. -- `topics`: small list of stable routing tags (create if new); prefer concise nouns. -- `uid`: leave null if unsure; the caller will derive an idempotency key. - -Aspect classification (REQUIRED β€” populate aspect_classification for every record) -The `aspect_classification` field tells the agent which planning domain this constraint belongs to -without any keyword or regex scanning. You MUST always populate it. Fields: - -- aspect_id (string): stable lower_snake_case slug for the planning aspect. Reuse the same slug - for the same life-area across turns, e.g. "sleep_window", "work_window", "gym_training", - "field_hockey", "morning_commute", "dog_walk", "school_run". -- aspect_label (string): human-readable display name. -- category (string): use one of the well-known values when applicable: - sleep | work | exercise | family | pet | social | transport | hobby | nutrition | health | learning - Any other lowercase slug is valid for domains not in this list. -- frame_slot (string or null): ONLY set when the constraint maps to a legacy planning slot. - Valid values: "sleep_target" (sleep-window constraints) or "work_window" (work-hours constraints). - Null for everything else. -- is_startup_prefetch (bool): true when this constraint anchors the whole day and must be fetched - before the user speaks β€” sleep schedule, work window, or primary transport commitment. - False for optional activities and session-specific overrides. -- schedule_start (string or null): HH:MM extracted from the utterance, if present. -- schedule_end (string or null): HH:MM extracted from the utterance, if present. -- duration_min (integer or null): duration in minutes if stated. -- is_conditional (bool): true when the constraint only applies given another aspect being present - or absent. -- conditional_on_absent (list[string]): aspect_id values that must be absent for this to apply. -- conditional_on_present (list[string]): aspect_id values that must be present. -- excludes_aspect_ids (list[string]): aspect_id values that should not be scheduled when this - aspect is confirmed (e.g. long commute excludes the gym). - -Examples: - Sleep: aspect_id=sleep_window, category=sleep, frame_slot=sleep_target, is_startup_prefetch=true - Work: aspect_id=work_window, category=work, frame_slot=work_window, is_startup_prefetch=true - Gym: aspect_id=gym_training, category=exercise, frame_slot=null, is_startup_prefetch=false - Commute: aspect_id=morning_commute, category=transport, frame_slot=null, is_startup_prefetch=true - Dog: aspect_id=dog_walk, category=pet, frame_slot=null, is_startup_prefetch=false - -Procedure: -1) If needed, call constraint_query_types to shortlist types for the stage/event_types. -2) Call constraint_query_constraints to check for duplicates/supersedes. -3) Call constraint_upsert_constraint with the constraint_record, including an event payload when possible. -4) If not using upsert event payload, call constraint_log_event separately. -""".strip() - - -def build_constraint_extractor_agent( - *, model_client: OpenAIChatCompletionClient, tools: Optional[List[Any]] = None -) -> AssistantAgent: - """Build the durable-constraint extractor agent. - - Structured `output_content_type` parsing is intentionally disabled here because - MCP tool adapters are non-strict by default; OpenAI parse mode rejects non-strict - tools before execution. - """ - return AssistantAgent( - name="ConstraintExtractorAgent", - model_client=model_client, - tools=tools, - system_message=CONSTRAINT_EXTRACTOR_SYSTEM_PROMPT, - reflect_on_tool_use=False, - max_tool_iterations=3, - ) - - -def _strip_markdown_json_fence(payload: str) -> str: - """Return JSON payload text without optional markdown code fences.""" - # TODO(refactor,typed-contracts): Remove markdown-fence normalization by - # requiring strict structured output from extractor responses. - text = payload.strip() - if not text.startswith("```"): - return text - lines = text.splitlines() - if lines and lines[0].startswith("```"): - lines = lines[1:] - if lines and lines[-1].strip() == "```": - lines = lines[:-1] - return "\n".join(lines).strip() - - -def _parse_constraint_extraction_response(response: Any) -> ConstraintExtractionOutput: - """Parse an AutoGen response into `ConstraintExtractionOutput`. - - The extractor prompt enforces JSON output, so this parser accepts either: - - an already validated `ConstraintExtractionOutput`, - - a dict payload, - - raw JSON text (optionally in markdown fences). - - # TODO(refactor,typed-contracts): Remove raw string parsing path and depend - # only on typed model payloads. - """ - content = getattr(getattr(response, "chat_message", None), "content", None) - if isinstance(content, ConstraintExtractionOutput): - return content - if isinstance(content, str): - cleaned = _strip_markdown_json_fence(content) - return ConstraintExtractionOutput.model_validate_json(cleaned) - return ConstraintExtractionOutput.model_validate(content) - - -class NotionConstraintExtractor: - """LLM-powered extractor that calls MCP tools to persist durable constraints.""" - - def __init__( - self, - *, - model_client: OpenAIChatCompletionClient, - tools: List[Any], - ) -> None: - self._agent = build_constraint_extractor_agent( - model_client=model_client, tools=tools - ) - self._agent_tool = AgentTool( - agent=self._agent, return_value_as_last_message=True - ) - - async def extract_and_upsert( - self, - handoff: ConstraintHandoff, - ) -> ConstraintExtractionOutput | None: - """Extract a durable constraint record from a user utterance and persist it via MCP tools.""" - if not handoff.user_utterance.strip(): - return None - - payload = handoff.model_dump(mode="json") - task = json.dumps(payload, ensure_ascii=False) - response = await with_timeout( - "notion:constraint-extract", - self._agent_tool.run_json({"task": task}, CancellationToken()), - timeout_s=TIMEBOXING_TIMEOUTS.notion_extract_s, - ) - try: - return _parse_constraint_extraction_response(response) - except Exception as exc: - raise RuntimeError( - "Constraint extractor returned invalid output payload" - ) from exc - - async def extract_and_upsert_constraint( - self, - *, - planned_date: str, - timezone: str, - stage_id: Optional[str], - user_utterance: str, - triggering_suggestion: Optional[str] = None, - impacted_event_types: Optional[List[str]] = None, - suggested_tags: Optional[List[str]] = None, - session_id: Optional[str] = None, - decision_scope: Optional[str] = None, - ) -> Dict[str, Any] | None: - """Tool-facing wrapper for timeboxing agent handoffs.""" - - if not user_utterance.strip(): - return None - # TODO(refactor): Validate planned_date via a Pydantic schema. - try: - parsed_date = date.fromisoformat(planned_date) - except Exception: - parsed_date = datetime.utcnow().date() - handoff = ConstraintHandoff( - planned_date=parsed_date, - timezone=timezone, - stage_id=stage_id, - user_utterance=user_utterance, - triggering_suggestion=triggering_suggestion, - impacted_event_types=impacted_event_types or [], - suggested_tags=suggested_tags or [], - session_id=session_id, - decision_scope=decision_scope, - ) - extracted = await self.extract_and_upsert(handoff) - return extracted.model_dump() if extracted else None - - -__all__ = [ - "AspectClassificationPayload", - "CONSTRAINT_EXTRACTOR_SYSTEM_PROMPT", - "ConstraintExtractionOutput", - "ConstraintHandoff", - "NotionConstraintExtractor", - "build_constraint_extractor_agent", -] diff --git a/src/fateforger/agents/timeboxing/patching.py b/src/fateforger/agents/timeboxing/patching.py deleted file mode 100644 index 15342694..00000000 --- a/src/fateforger/agents/timeboxing/patching.py +++ /dev/null @@ -1,556 +0,0 @@ -"""Typed domain-op patching for timebox plans. - -Uses an AutoGen ``AssistantAgent`` with the ``TBPatch`` JSON schema -injected into the system prompt (schema-in-prompt approach). The LLM -produces a raw JSON ``TBPatch`` response, which is parsed and applied -deterministically via ``apply_tb_ops()``. - -Note: ``output_content_type=TBPatch`` is intentionally NOT used because -OpenAI's ``response_format`` rejects ``oneOf`` from Pydantic discriminated -unions and OpenRouter structured output hung on complex schemas on the hosts measured. - -The legacy interface (``Timebox`` in / out) is preserved via conversion -helpers so existing nodes can transition incrementally. -""" - -from __future__ import annotations - -import json -import logging -import os -import time -from collections.abc import Iterator -from typing import Any, Callable, Iterable, List, Literal - -from autogen_agentchat.agents import AssistantAgent -from autogen_agentchat.messages import TextMessage -from autogen_core import CancellationToken - -from fateforger.debug.diag import with_timeout -from fateforger.llm import build_autogen_chat_client -from fateforger.llm.toon import toon_encode - -from .actions import TimeboxAction -from .constants import TIMEBOXING_TIMEOUTS -from .planning_policy import ( - PLANNING_POLICY_VERSION, - QUALITY_RUBRIC_PROMPT, - SHARED_PLANNING_POLICY_PROMPT, - STAGE3_OUTLINE_PROMPT, - STAGE4_REFINEMENT_PROMPT, -) -from .preferences import Constraint -from .tb_models import TBPlan -from .tb_ops import TBPatch, apply_tb_ops -from .timebox import Timebox -from .toon_views import constraints_rows - -logger = logging.getLogger(__name__) - -# ── System prompt for the patcher agent ────────────────────────────────── - -_PATCHER_SYSTEM_PROMPT = f"""\ -You are a timebox refinement assistant. You receive the current schedule -as a TBPlan JSON, plus a user instruction and optional constraints. - -Planning policy version: {PLANNING_POLICY_VERSION} - -**Your task**: produce a single ``TBPatch`` JSON with the minimal set of -typed domain operations that fulfills the user's request. - -Available operations (field ``op`` discriminator): -- ``ae`` (AddEvents): add one or more events. Set ``after`` to insert position. -- ``re`` (RemoveEvent): remove by index ``i``. -- ``ue`` (UpdateEvent): merge partial changes onto event at index ``i``. -- ``me`` (MoveEvent): reorder event from ``fr`` to ``to``. -- ``ra`` (ReplaceAll): replace the entire event list (only for full rebuilds). - -Time placement (field ``a`` discriminator on ``p``): -- ``ap`` (AfterPrev): starts after previous event ends; needs ``dur`` (ISO 8601). -- ``bn`` (BeforeNext): ends when next event starts; needs ``dur``. -- ``fs`` (FixedStart): pinned start time; needs ``st`` (HH:MM) and ``dur``. -- ``fw`` (FixedWindow): fixed start and end; needs ``st`` and ``et``. - -Event types (``t``): M (meeting), C (commute), DW (deep work), SW (shallow work), -PR (plan & review), H (habit), R (regeneration), BU (buffer), BG (background). - -Shared planning policy: -{SHARED_PLANNING_POLICY_PROMPT} - -Stage-aware behavior (read the "Planning context" JSON included in user_message): -- If context stage is ``Skeleton`` (or ``extra.mode`` is ``outline``), follow: -{STAGE3_OUTLINE_PROMPT} -- If context stage is ``Refine``, follow: -{STAGE4_REFINEMENT_PROMPT} - -Quality rubric guidance: -{QUALITY_RUBRIC_PROMPT} - -Rules: -- Prefer fine-grained ops (ue, re, ae) over ra. -- Keep immovable events (meetings, fixed windows) unchanged unless explicitly asked. -- Maintain time chain validity: at least one fixed anchor must exist. -- BG events must use fs or fw timing. -- If validation feedback lists rule violations, satisfy those first with minimal edits - and then apply the requested refinement while preserving intent. - -Return ONLY the TBPatch JSON. -""" - - -class TimeboxPatcher: - """Apply user-requested refinements to a ``TBPlan`` via typed domain ops. - - Uses an AutoGen ``AssistantAgent`` with the TBPatch JSON schema injected - into the system prompt. The LLM returns raw JSON which is parsed by - ``_extract_patch()``. This avoids OpenAI's ``response_format`` rejection - of ``oneOf`` (from Pydantic discriminated unions) and OpenRouter timeouts - with ``output_content_type``. - """ - - def __init__( - self, - *, - model_client: Any | None = None, - agent_type: str = "timebox_patcher", - max_attempts: int | None = None, - ) -> None: - """Initialize the patcher. - - Args: - model_client: An AutoGen chat model client. If ``None``, one is - built from the ``agent_type`` config key. - agent_type: Config key for ``build_autogen_chat_client``. - max_attempts: Maximum patch attempts before failing hard. - """ - self._model_client = model_client or build_autogen_chat_client( - agent_type, - parallel_tool_calls=False, - ) - env_attempts = _coerce_positive_int( - os.getenv("TIMEBOX_PATCHER_MAX_ATTEMPTS"), default=5 - ) - self._max_attempts = max_attempts if max_attempts is not None else env_attempts - self._max_attempts = max(1, int(self._max_attempts)) - - async def apply_patch( - self, - *, - stage: Literal["Refine"], - current: TBPlan, - user_message: str, - constraints: Iterable[Constraint] | None = None, - actions: Iterable[TimeboxAction] | None = None, - plan_validator: Callable[[TBPlan], Any] | None = None, - ) -> tuple[TBPlan, TBPatch]: - """Generate and apply a ``TBPatch`` to the current plan. - - Args: - current: The current ``TBPlan``. - user_message: The user's refinement instruction. - constraints: Active constraints (optional). - actions: Recent actions log (optional). - plan_validator: Optional callback to validate the patched plan. - Any raised exception is fed back into retry guidance. - - Returns: - Tuple of ``(patched_plan, patch)`` so callers can inspect - what changed. - - Raises: - ValueError: If the LLM output cannot be parsed as ``TBPatch``. - """ - if stage != "Refine": - raise ValueError( - f"TimeboxPatcher.apply_patch only supports stage='Refine'. Got {stage!r}." - ) - constraints_list = list(constraints or []) - actions_list = list(actions or []) - request_id = f"patch-{int(time.time() * 1000)}" - agent = AssistantAgent( - name="TimeboxPatcherAgent", - model_client=self._model_client, - system_message=_patcher_system_prompt_with_schema(), - reflect_on_tool_use=False, - ) - retry_feedback: str | None = None - last_error: Exception | None = None - last_retryable = True - - for attempt in range(1, self._max_attempts + 1): - context = _build_context( - current, - user_message, - constraints_list, - actions_list, - retry_feedback=retry_feedback, - ) - logger.debug( - "timebox_patcher request_id=%s attempt=%s/%s events=%s constraints=%s actions=%s", - request_id, - attempt, - self._max_attempts, - len(current.events), - len(constraints_list), - len(actions_list), - ) - if retry_feedback: - logger.debug( - "timebox_patcher request_id=%s retry_feedback=%s", - request_id, - retry_feedback, - ) - try: - response = await with_timeout( - "timeboxing:patcher", - agent.on_messages( - [TextMessage(content=context, source="user")], - CancellationToken(), - ), - timeout_s=TIMEBOXING_TIMEOUTS.skeleton_draft_s, - ) - raw_content = getattr(getattr(response, "chat_message", None), "content", None) - logger.debug( - "timebox_patcher request_id=%s attempt=%s raw_len=%s raw_content=%s", - request_id, - attempt, - len(raw_content) if isinstance(raw_content, str) else None, - _to_log_string(raw_content), - ) - patch = _extract_patch(response) - logger.debug( - "timebox_patcher request_id=%s attempt=%s patch=%s", - request_id, - attempt, - patch.model_dump_json(), - ) - patched = apply_tb_ops(current, patch) - if plan_validator is not None: - plan_validator(patched) - logger.info( - "timebox_patcher request_id=%s success attempt=%s/%s ops=%s", - request_id, - attempt, - self._max_attempts, - len(patch.ops), - ) - return patched, patch - except Exception as exc: - last_error = exc - retryable = _is_retryable_patch_error(exc) - last_retryable = retryable - retry_feedback = _build_retry_feedback(error=exc) - logger.warning( - "timebox_patcher request_id=%s failed attempt=%s/%s retryable=%s error=%s", - request_id, - attempt, - self._max_attempts, - retryable, - retry_feedback, - ) - if not retryable: - break - continue - - assert last_error is not None - qualifier = "non-retryable " if not last_retryable else "" - raise ValueError( - f"Timebox patch failed after {attempt} attempts due to {qualifier}error: {last_error}" - ) from last_error - - async def apply_patch_legacy( - self, - *, - stage: Literal["Refine"], - current: Timebox, - user_message: str, - constraints: Iterable[Constraint] | None = None, - actions: Iterable[TimeboxAction] | None = None, - ) -> Timebox: - """Legacy interface: ``Timebox`` in β†’ ``Timebox`` out. - - Converts to ``TBPlan``, patches, and converts back. - Preserves backward compat with existing ``StageRefineNode``. - - Args: - current: The current ``Timebox``. - user_message: User refinement instruction. - constraints: Active constraints. - actions: Recent actions log. - - Returns: - A new ``Timebox`` with the patch applied. - """ - from .timebox import tb_plan_to_timebox, timebox_to_tb_plan - - tb_plan = timebox_to_tb_plan(current) - patched_plan, _ = await self.apply_patch( - stage=stage, - current=tb_plan, - user_message=user_message, - constraints=constraints, - actions=actions, - ) - return tb_plan_to_timebox(patched_plan) - - -# ── Internal helpers ───────────────────────────────────────────────────── - - -def _patcher_system_prompt_with_schema() -> str: - """Build patcher system prompt with the TBPatch JSON schema appended. - - The schema is included so the LLM produces valid JSON matching the - ``TBPatch`` structure without relying on ``response_format`` (which - rejects ``oneOf`` from discriminated unions). - - Returns: - Full system prompt string. - """ - schema_json = json.dumps(TBPatch.model_json_schema(), indent=2) - return ( - _PATCHER_SYSTEM_PROMPT - + f"\n\nTBPatch JSON Schema:\n```json\n{schema_json}\n```" - + "\n\nReturn ONLY the raw TBPatch JSON object β€” no markdown fences, no commentary." - ) - - -def _build_context( - plan: TBPlan, - user_message: str, - constraints: Iterable[Constraint], - actions: Iterable[TimeboxAction], - retry_feedback: str | None = None, -) -> str: - """Build the prompt context for the patcher agent. - - Args: - plan: Current TBPlan. - user_message: User's refinement instruction. - constraints: Active constraints. - actions: Recent actions. - - Returns: - Formatted prompt string. - """ - plan_json = plan.model_dump_json(indent=2) - constraints_list = list(constraints) - constraints_toon = "" - if constraints_list: - constraints_toon = toon_encode( - name="constraints", - rows=constraints_rows(constraints_list), - fields=["name", "necessity", "scope", "status", "source", "description"], - ) - actions_text = _format_actions(actions) - - context = ( - f"Current TBPlan:\n```json\n{plan_json}\n```\n\n" - f"User request: {user_message}\n\n" - f"{constraints_toon}\n" - f"Recent actions:\n{actions_text}\n\n" - "Produce the TBPatch JSON with minimal ops to fulfill the request." - ) - if retry_feedback: - context += ( - "\n\nPrevious patch attempt failed.\n" - f"Validation/apply error: {retry_feedback}\n" - "Return a corrected TBPatch that resolves this error while preserving user intent." - ) - return context - - -def _format_actions(actions: Iterable[TimeboxAction]) -> str: - """Format action log for prompt context. - - Args: - actions: Iterable of TimeboxAction. - - Returns: - Formatted string. - """ - lines: List[str] = [] - for action in actions: - details = [] - if action.from_time: - details.append(f"from {action.from_time}") - if action.to_time: - details.append(f"to {action.to_time}") - detail_text = " ".join(details) - reason = f" | reason: {action.reason}" if action.reason else "" - lines.append(f"- {action.kind} {action.summary} {detail_text}".strip() + reason) - return "\n".join(lines) if lines else "- (none)" - - -def _extract_patch(response: Any) -> TBPatch: - """Extract ``TBPatch`` from an AutoGen agent response. - - Args: - response: The ``Response`` object from ``agent.on_messages()``. - - Returns: - Parsed ``TBPatch``. - - Raises: - ValueError: If the response cannot be parsed as ``TBPatch``. - """ - msg = response.chat_message - content = getattr(msg, "content", None) - - # If AutoGen returned a structured TBPatch directly - if isinstance(content, TBPatch): - return content - - # Try parsing from string (strip markdown fences if present) - if isinstance(content, str): - # TODO(refactor,typed-contracts): Remove markdown-fence stripping fallback. - # Enforce strict typed tool/message output so TBPatch is parsed without - # free-text normalization. - text = content.strip() - if text.startswith("```"): - # Remove opening ```json or ``` and closing ``` - lines = text.split("\n") - lines = lines[1:] # drop opening fence - if lines and lines[-1].strip() == "```": - lines = lines[:-1] # drop closing fence - text = "\n".join(lines) - try: - return TBPatch.model_validate_json(text) - except Exception as exc: - raise ValueError(f"TBPatch parse/validation failed: {exc}") from exc - - # Try from dict - if isinstance(content, dict): - try: - return TBPatch.model_validate(content) - except Exception as exc: - raise ValueError(f"TBPatch validation failed from dict payload: {exc}") from exc - - raise ValueError( - f"Could not extract TBPatch from response: {type(content).__name__}" - ) - - -def _coerce_positive_int(value: str | None, *, default: int) -> int: - """Parse a positive integer from env-like input, with safe fallback.""" - try: - if value is None: - return default - parsed = int(str(value).strip()) - if parsed < 1: - return default - return parsed - except Exception: - return default - - -def _iter_exception_chain(error: Exception) -> Iterator[Exception]: - seen: set[int] = set() - current: Exception | None = error - while current is not None and id(current) not in seen: - seen.add(id(current)) - yield current - if current.__cause__ is not None: - current = current.__cause__ - continue - current = current.__context__ - - -def _status_code_from_exception(error: Exception) -> int | None: - for attr in ("status_code", "http_status", "status"): - value = getattr(error, attr, None) - if isinstance(value, int): - return value - response = getattr(error, "response", None) - value = getattr(response, "status_code", None) - if isinstance(value, int): - return value - return None - - -def _is_retryable_patch_error(error: Exception) -> bool: - non_retryable_types = { - "AuthenticationError", - "PermissionDeniedError", - "BadRequestError", - } - for item in _iter_exception_chain(error): - if type(item).__name__ in non_retryable_types: - return False - status = _status_code_from_exception(item) - if status is None: - continue - if status in {400, 401, 403, 404, 422}: - return False - if status in {408, 409, 425, 429}: - return True - if status >= 500: - return True - return True - - -def _to_log_string(value: Any) -> str: - """Render patcher values as compact log-safe strings.""" - try: - if hasattr(value, "model_dump_json"): - text = value.model_dump_json() # type: ignore[call-arg] - elif isinstance(value, (dict, list)): - text = json.dumps(value, ensure_ascii=False, default=str) - else: - text = str(value) - except Exception: - text = repr(value) - return _truncate(text, max_chars=6000) - - -def _truncate(value: str, *, max_chars: int) -> str: - """Truncate long log lines while preserving length metadata.""" - if len(value) <= max_chars: - return value - return value[: max(0, max_chars - 16)] + f" …(truncated,{len(value)})" - - -def _build_retry_feedback(*, error: Exception) -> str: - """Build concise, structured retry guidance from apply/validation exceptions.""" - head = str(error).strip() or type(error).__name__ - lines: list[str] = [head] - details = _extract_error_details(error) - if details: - lines.append("Violations:") - lines.extend(f"- {item}" for item in details) - return _truncate("\n".join(lines), max_chars=1200) - - -def _extract_error_details(error: Exception) -> list[str]: - """Extract normalized validation/apply details from nested exceptions.""" - details: list[str] = [] - seen: set[int] = set() - current: Exception | None = error - while current is not None and id(current) not in seen: - seen.add(id(current)) - extractor = getattr(current, "errors", None) - if callable(extractor): - try: - raw = extractor() - except Exception: - raw = None - if isinstance(raw, list): - for item in raw[:6]: - if not isinstance(item, dict): - continue - loc = item.get("loc") - loc_text = ( - ".".join(str(part) for part in loc) - if isinstance(loc, (list, tuple)) and loc - else "root" - ) - typ = str(item.get("type") or "validation_error") - msg = str(item.get("msg") or "").strip() - details.append( - f"{typ} at {loc_text}: {msg}".strip(": ") - ) - current = current.__cause__ - return details - - -__all__ = ["TimeboxPatcher"] diff --git a/src/fateforger/agents/timeboxing/planning_aspects.py b/src/fateforger/agents/timeboxing/planning_aspects.py deleted file mode 100644 index 9f400f9d..00000000 --- a/src/fateforger/agents/timeboxing/planning_aspects.py +++ /dev/null @@ -1,382 +0,0 @@ -""" -Planning-aspects graph model. - -A *planning aspect* is anything the user cares about when building their day: -a recurring activity, a time-bound commitment, a lifestyle preference, a care -responsibility. Aspects are typed by an open ``category`` string (no fixed -enum) so the taxonomy grows with the user β€” "gym", "field_hockey", "dog_walk", -"school_run" are all valid alongside the well-known seeds. - -Graph structure ---------------- -- :class:`PlanningAspect` β€” node: one area of life / activity -- :class:`ExclusionRelation` β€” directed edge: A present β‡’ B should not be -- :class:`ConditionalPreference` β€” directed edge: A absent β‡’ prefer B - -Seed categories ---------------- -``SEED_ASPECT_CATEGORIES`` contains well-known category slugs so callers can -reference them without hard-coding bare strings, but they are *not* enforced. -Any LLM-generated string is a valid ``category``. - -LLM classification contract ------------------------------ -When a constraint is extracted, the LLM assignes a -:class:`ConstraintAspectClassification` object and stores it as -``hints["aspect_classification"]``. Agent code that previously used keyword -scanning or regex must read this field instead. -""" - -from __future__ import annotations - -from typing import Any - -from pydantic import BaseModel, Field - -# --------------------------------------------------------------------------- -# Seed category slugs β€” open strings, not an enum -# --------------------------------------------------------------------------- - - -class SeedAspectCategory: - """Well-known ``category`` slugs for common planning aspects. - - Import these to avoid bare string literals in code that branches on - category. The values are intentionally lower-snake-case and stable. - """ - - SLEEP = "sleep" - WORK = "work" - EXERCISE = "exercise" - FAMILY = "family" - PET = "pet" - SOCIAL = "social" - TRANSPORT = "transport" - HOBBY = "hobby" - NUTRITION = "nutrition" - HEALTH = "health" - LEARNING = "learning" - - -# --------------------------------------------------------------------------- -# LLM classification record β€” stored in hints["aspect_classification"] -# --------------------------------------------------------------------------- - - -class ConstraintAspectClassification(BaseModel): - """Structured metadata the LLM assigns to a constraint at extraction time. - - Stored as ``constraint.hints["aspect_classification"]`` (serialised as a - plain dict). Agent code that reads this field MUST NOT fall back to - keyword or regex scanning; use ``None`` / the field's default instead. - - Attributes - ---------- - aspect_id: - Stable, LLM-assigned slug for the planning aspect this constraint - belongs to (e.g. ``"gym_training"``, ``"field_hockey"``, - ``"morning_routine"``). Used as the graph-node identifier. - aspect_label: - Human-readable display name (e.g. ``"Gym training"``). - category: - Open-string category slug. Use :class:`SeedAspectCategory` constants - for the common cases; any string is valid. - frame_slot: - If the constraint maps to a well-known contracts frame slot for legacy - integration, set this (e.g. ``"sleep_target"``, ``"work_window"``). - ``None`` when there is no frame-slot mapping. - is_startup_prefetch: - ``True`` when this constraint should be loaded at Stage-1 start so - the agent can reason about the day without waiting for the full - retrieval. Set this for constraints that anchor the day (sleep - schedule, work window, key transport). - schedule_start: - ``HH:MM`` if the constraint encodes a known start time (e.g. wake-up - time, work start). ``None`` when no concrete time was stated. - schedule_end: - ``HH:MM`` if the constraint encodes a known end time. ``None`` - otherwise. - duration_min: - Minimum or typical duration in minutes if stated. ``None`` otherwise. - is_conditional: - ``True`` when this constraint only applies given other aspects being - present or absent (e.g. "if I have a late meeting, skip the gym"). - conditional_on_absent: - List of ``aspect_id`` values that must be *absent* (not confirmed on - the day) for this constraint to apply. - conditional_on_present: - List of ``aspect_id`` values that must be *present* for this - constraint to apply. - excludes_aspect_ids: - When this aspect is confirmed, the listed ``aspect_id`` values should - not be suggested. Drives :class:`ExclusionRelation` graph edges. - """ - - aspect_id: str - aspect_label: str - category: str - frame_slot: str | None = None - is_startup_prefetch: bool = False - schedule_start: str | None = None # HH:MM - schedule_end: str | None = None # HH:MM - duration_min: int | None = None - is_conditional: bool = False - conditional_on_absent: list[str] = Field(default_factory=list) - conditional_on_present: list[str] = Field(default_factory=list) - excludes_aspect_ids: list[str] = Field(default_factory=list) - - @classmethod - def from_hints( - cls, hints: dict[str, Any] - ) -> "ConstraintAspectClassification | None": - """Deserialise from a constraint ``hints`` dict; returns ``None`` on failure.""" - raw = hints.get("aspect_classification") if isinstance(hints, dict) else None - if not isinstance(raw, dict): - return None - try: - return cls.model_validate(raw) - except Exception: - return None - - -# --------------------------------------------------------------------------- -# Graph nodes and edges -# --------------------------------------------------------------------------- - - -class PlanningAspect(BaseModel): - """One activity or area-of-life that shapes how a day should be planned. - - Attributes - ---------- - aspect_id: - Stable slug, LLM-assigned (e.g. ``"gym_training"``). Used as the - node identifier in the graph. - label: - Human-readable name. - category: - Open category string. Use :class:`SeedAspectCategory` for common - values. - is_confirmed: - ``True`` when a calendar event for this aspect already exists on the - session day (set by the calendar-pass resolver, not by the LLM). - is_desired: - ``False`` when the user has said they *don't* want this aspect - today (e.g. "skip the gym today"). - desire_strength: - Soft-preference weight in ``[0.0, 1.0]``. 1.0 = locked/must-have; - 0.0 = explicitly excluded. Default 0.5. - schedule_start: - Preferred or required start time (``HH:MM``). ``None`` when flexible. - schedule_end: - Preferred or required end time (``HH:MM``). ``None`` when flexible. - duration_min: - Typical or minimum duration in minutes. ``None`` when unknown. - """ - - aspect_id: str - label: str - category: str - is_confirmed: bool = False - is_desired: bool = True - desire_strength: float = Field(default=0.5, ge=0.0, le=1.0) - schedule_start: str | None = None # HH:MM - schedule_end: str | None = None # HH:MM - duration_min: int | None = None - - -class ExclusionRelation(BaseModel): - """An edge expressing: if *aspect_a* is confirmed, *aspect_b* should not be scheduled. - - Set ``symmetric=True`` when the exclusion is mutual (e.g. two aspects - that cannot both fit in the day). - """ - - aspect_a: str # aspect_id - aspect_b: str # aspect_id - symmetric: bool = False - - -class ConditionalPreference(BaseModel): - """An edge expressing: when *when_absent* is not confirmed, prefer *prefer*. - - Strength follows the same 0–1 scale as :attr:`PlanningAspect.desire_strength`. - """ - - when_absent: str # aspect_id - prefer: str # aspect_id - strength: float = Field(default=0.5, ge=0.0, le=1.0) - - -# --------------------------------------------------------------------------- -# Graph container -# --------------------------------------------------------------------------- - - -class PlanningAspectGraph(BaseModel): - """Typed graph of planning aspects and their relationships for one session. - - Accessors filter by :attr:`PlanningAspect.category`; they do **not** use - string scanning β€” they rely entirely on the structured ``category`` values - assigned by :class:`ConstraintAspectClassification` at extraction time. - """ - - aspects: list[PlanningAspect] = Field(default_factory=list) - exclusions: list[ExclusionRelation] = Field(default_factory=list) - conditional_preferences: list[ConditionalPreference] = Field(default_factory=list) - - # ------------------------------------------------------------------ - # Typed aspect accessors (structural, no string scanning) - # ------------------------------------------------------------------ - - def by_category(self, category: str) -> list[PlanningAspect]: - """Return all aspects whose ``category`` equals *category*.""" - return [a for a in self.aspects if a.category == category] - - def sleep_windows(self) -> list[PlanningAspect]: - """Return aspects in the ``sleep`` category.""" - return self.by_category(SeedAspectCategory.SLEEP) - - def work_windows(self) -> list[PlanningAspect]: - """Return aspects in the ``work`` category.""" - return self.by_category(SeedAspectCategory.WORK) - - def exercise_aspects(self) -> list[PlanningAspect]: - """Return aspects in the ``exercise`` category.""" - return self.by_category(SeedAspectCategory.EXERCISE) - - def confirmed_aspects(self) -> list[PlanningAspect]: - """Return all aspects that are confirmed on calendar.""" - return [a for a in self.aspects if a.is_confirmed] - - def desired_aspects(self) -> list[PlanningAspect]: - """Return aspects that are desired and not explicitly excluded.""" - return [a for a in self.aspects if a.is_desired and a.desire_strength > 0.0] - - # ------------------------------------------------------------------ - # Graph helpers - # ------------------------------------------------------------------ - - def excluded_by(self, aspect_id: str) -> list[str]: - """Return aspect_ids that are excluded when *aspect_id* is confirmed.""" - excluded: list[str] = [] - for excl in self.exclusions: - if excl.aspect_a == aspect_id: - excluded.append(excl.aspect_b) - elif excl.symmetric and excl.aspect_b == aspect_id: - excluded.append(excl.aspect_a) - return excluded - - def preferred_when_absent(self, aspect_id: str) -> list[tuple[str, float]]: - """Return (aspect_id, strength) pairs preferred when *aspect_id* is absent.""" - return [ - (pref.prefer, pref.strength) - for pref in self.conditional_preferences - if pref.when_absent == aspect_id - ] - - def get_aspect(self, aspect_id: str) -> PlanningAspect | None: - """Look up an aspect by its stable slug.""" - for aspect in self.aspects: - if aspect.aspect_id == aspect_id: - return aspect - return None - - # ------------------------------------------------------------------ - # Mutation helpers - # ------------------------------------------------------------------ - - def upsert_aspect_from_classification( - self, cls: ConstraintAspectClassification - ) -> PlanningAspect: - """Insert or update a :class:`PlanningAspect` node from a classification. - - Existing node fields are updated in-place so caller can overlay - ``is_confirmed`` separately after the calendar pass. - """ - existing = self.get_aspect(cls.aspect_id) - if existing is not None: - existing.label = cls.aspect_label - existing.category = cls.category - if cls.schedule_start: - existing.schedule_start = cls.schedule_start - if cls.schedule_end: - existing.schedule_end = cls.schedule_end - if cls.duration_min is not None: - existing.duration_min = cls.duration_min - return existing - - aspect = PlanningAspect( - aspect_id=cls.aspect_id, - label=cls.aspect_label, - category=cls.category, - schedule_start=cls.schedule_start, - schedule_end=cls.schedule_end, - duration_min=cls.duration_min, - ) - self.aspects.append(aspect) - - for excluded_id in cls.excludes_aspect_ids: - if not any( - e.aspect_a == cls.aspect_id and e.aspect_b == excluded_id - for e in self.exclusions - ): - self.exclusions.append( - ExclusionRelation(aspect_a=cls.aspect_id, aspect_b=excluded_id) - ) - - for absent_id in cls.conditional_on_absent: - if not any( - p.when_absent == absent_id and p.prefer == cls.aspect_id - for p in self.conditional_preferences - ): - self.conditional_preferences.append( - ConditionalPreference(when_absent=absent_id, prefer=cls.aspect_id) - ) - - return aspect - - -# --------------------------------------------------------------------------- -# Pre-population helper -# --------------------------------------------------------------------------- - - -def make_seed_aspect_graph() -> PlanningAspectGraph: - """Return an empty :class:`PlanningAspectGraph` pre-seeded with stub nodes for the - well-known categories. - - These stubs carry no schedule times; they are present so the graph is not - empty when a new user starts their first session. The LLM will fill in - concrete times and preferences as the user speaks. - """ - return PlanningAspectGraph( - aspects=[ - PlanningAspect( - aspect_id="sleep_window", - label="Sleep window", - category=SeedAspectCategory.SLEEP, - ), - PlanningAspect( - aspect_id="work_window", - label="Work window", - category=SeedAspectCategory.WORK, - ), - PlanningAspect( - aspect_id="exercise", - label="Exercise", - category=SeedAspectCategory.EXERCISE, - ), - ] - ) - - -__all__ = [ - "ConstraintAspectClassification", - "ConditionalPreference", - "ExclusionRelation", - "PlanningAspect", - "PlanningAspectGraph", - "SeedAspectCategory", - "make_seed_aspect_graph", -] diff --git a/src/fateforger/agents/timeboxing/planning_policy.py b/src/fateforger/agents/timeboxing/planning_policy.py deleted file mode 100644 index 8c95c859..00000000 --- a/src/fateforger/agents/timeboxing/planning_policy.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Shared planning policy text for Stage 3 + Stage 4 prompts.""" - -from __future__ import annotations - -PLANNING_POLICY_VERSION = "stage3-stage4-policy-v1-2026-02-14" - -QUALITY_LEVEL_LABELS: dict[int, str] = { - 0: "Insufficient", - 1: "Minimal", - 2: "Okay", - 3: "Detailed", - 4: "Ultra", -} - -SHARED_PLANNING_POLICY_PROMPT = """ -Shared planning policy (must be applied in all planning stages): -- Keep at least one fixed chain anchor (`fs` or `fw`) for non-background events. -- Non-background events must not overlap. -- Background (`BG`) events are exempt from overlap checks, but must use fixed timing (`fs` or `fw`). -- Use clear, neutral event names and short practical descriptions. -- Choose timing mode intentionally: - - `fw` for immovable fixed windows (meetings, hard commitments). - - `fs` for fixed starts with flexible end. - - `ap` for flow-chained blocks after the previous block. - - `bn` for reverse-fit blocks that end at the next anchor. -- Plan in block terms first (DW/SW/PR/R/BU/H), not per-task minute precision. -""".strip() - -STAGE3_OUTLINE_PROMPT = """ -Stage 3 outline mode: -- Produce a short, editable ordering outline for the day. -- Keep output glanceable and compact. -- Keep durations coarse for flexible blocks. -- Show exact times only for anchored blocks (`fs`/`fw`) or if user explicitly requested exact times. -- Do not fully optimize micro-buffers and tiny details yet. -""".strip() - -STAGE4_REFINEMENT_PROMPT = """ -Stage 4 refine mode: -- Apply macro pass first: - 1) lock anchors and immovables, - 2) place deep-work/shallow-work blocks coherently, - 3) keep schedule non-overlapping and practical. -- Then apply micro pass: - 1) improve task-to-block mapping, - 2) add/rebalance buffers and recovery blocks where needed, - 3) improve sequencing quality without breaking anchors. -- Prefer minimal patch operations that preserve existing intent and ordering unless change is requested. -""".strip() - -QUALITY_RUBRIC_PROMPT = """ -Quality rubric (self-check before returning TBPatch): -- 0 Insufficient: missing anchors or invalid/overlapping sequence. -- 1 Minimal: valid skeleton but weak task/block quality. -- 2 Okay: valid schedule with coherent block allocation and core intent coverage. -- 3 Detailed: includes sensible buffers/recovery and tighter execution detail. -- 4 Ultra: high-quality sequence with robust buffers/review polish. -Target: raise quality when possible while preserving user intent. -""".strip() - -__all__ = [ - "PLANNING_POLICY_VERSION", - "QUALITY_LEVEL_LABELS", - "SHARED_PLANNING_POLICY_PROMPT", - "STAGE3_OUTLINE_PROMPT", - "STAGE4_REFINEMENT_PROMPT", - "QUALITY_RUBRIC_PROMPT", -] diff --git a/src/fateforger/agents/timeboxing/preferences.py b/src/fateforger/agents/timeboxing/preferences.py index ed5bde0a..3c99b082 100644 --- a/src/fateforger/agents/timeboxing/preferences.py +++ b/src/fateforger/agents/timeboxing/preferences.py @@ -10,8 +10,6 @@ from pydantic import Field as PydanticField from sqlalchemy import Column from sqlalchemy import DateTime as SQLDateTime -from sqlalchemy import and_, or_, select -from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker from sqlalchemy.types import JSON as SAJSON from sqlmodel import Field, SQLModel @@ -99,525 +97,6 @@ class ConstraintBatch(PydanticBaseModel): notes: Optional[str] = None -class ConstraintStore: - def __init__(self, sessionmaker: async_sessionmaker[AsyncSession]) -> None: - """Create a constraint store backed by an async SQLAlchemy sessionmaker.""" - self._sessionmaker = sessionmaker - - async def add_constraints( - self, - *, - user_id: str, - channel_id: Optional[str], - thread_ts: Optional[str], - constraints: List[ConstraintBase], - ) -> List[Constraint]: - """Persist a batch of constraints for a user/thread. - - Shared scopes (`PROFILE`, `DATESPAN`) are canonicalized across threads to - avoid local-mirror accumulation. - """ - if not constraints: - return [] - rows = [ - _constraint_row( - constraint, - user_id=user_id, - channel_id=channel_id, - thread_ts=thread_ts, - ) - for constraint in constraints - ] - async with self._sessionmaker() as session: - incoming_shared = [ - row - for row in rows - if row.scope in {ConstraintScope.PROFILE, ConstraintScope.DATESPAN} - ] - incoming_session = [ - row - for row in rows - if row.scope not in {ConstraintScope.PROFILE, ConstraintScope.DATESPAN} - ] - persisted: list[Constraint] = [] - - if incoming_shared: - stmt = select(Constraint).where( - Constraint.user_id == user_id, - Constraint.scope.in_( - [ConstraintScope.PROFILE, ConstraintScope.DATESPAN] - ), - ) - result = await session.execute(stmt) - existing_shared = list(result.scalars().all()) - existing_by_key = _group_constraints_by_semantics(existing_shared) - - for incoming in incoming_shared: - key = _constraint_semantic_key(incoming) - matches = list(existing_by_key.get(key) or []) - if not matches: - incoming.channel_id = None - incoming.thread_ts = None - session.add(incoming) - existing_by_key.setdefault(key, []).append(incoming) - persisted.append(incoming) - continue - - canonical_existing = _canonical_constraint(matches) - canonical_candidate = _canonical_constraint([*matches, incoming]) - if canonical_candidate is incoming: - payload = incoming.model_dump( - exclude={ - "id", - "user_id", - "channel_id", - "thread_ts", - "created_at", - "updated_at", - } - ) - _apply_constraint_payload(canonical_existing, payload) - canonical_existing.channel_id = None - canonical_existing.thread_ts = None - persisted.append(canonical_existing) - - if incoming_session: - session.add_all(incoming_session) - persisted.extend(incoming_session) - - await session.commit() - for row in _dedupe_constraint_rows_by_id(persisted): - await session.refresh(row) - return _dedupe_constraint_rows_by_id(persisted) - - async def upsert_constraints( - self, - *, - user_id: str, - channel_id: Optional[str], - thread_ts: Optional[str], - constraints: List[ConstraintBase], - ) -> Dict[str, int]: - """Persist constraints while avoiding duplicate shared-scope inserts.""" - if not constraints: - return {"added": 0, "skipped": 0, "total": 0} - async with self._sessionmaker() as session: - stmt = select(Constraint).where(Constraint.user_id == user_id) - if channel_id: - stmt = stmt.where(Constraint.channel_id == channel_id) - result = await session.execute(stmt) - existing_rows = list(result.scalars().all()) - seen_keys = {_constraint_row_identity(row) for row in existing_rows} - - to_add: list[Constraint] = [] - skipped = 0 - for constraint in constraints: - scope = constraint.scope or ConstraintScope.SESSION - target_thread_ts = ( - None - if scope in (ConstraintScope.PROFILE, ConstraintScope.DATESPAN) - else thread_ts - ) - row = _constraint_row( - constraint, - user_id=user_id, - channel_id=channel_id, - thread_ts=target_thread_ts, - ) - key = _constraint_row_identity(row) - if key in seen_keys: - skipped += 1 - continue - seen_keys.add(key) - to_add.append(row) - - if to_add: - session.add_all(to_add) - await session.commit() - for row in to_add: - await session.refresh(row) - return {"added": len(to_add), "skipped": skipped, "total": len(constraints)} - - async def list_constraints( - self, - *, - user_id: str, - channel_id: Optional[str] = None, - thread_ts: Optional[str] = None, - status: Optional[ConstraintStatus] = None, - scope: Optional[ConstraintScope] = None, - include_shared_scopes: bool = False, - ) -> List[Constraint]: - """List constraints for a user with optional filters.""" - async with self._sessionmaker() as session: - stmt = select(Constraint).where(Constraint.user_id == user_id) - if include_shared_scopes and thread_ts and channel_id: - stmt = stmt.where( - or_( - and_( - Constraint.channel_id == channel_id, - Constraint.thread_ts == thread_ts, - ), - Constraint.scope.in_( - [ConstraintScope.PROFILE, ConstraintScope.DATESPAN] - ), - ) - ) - else: - if channel_id: - stmt = stmt.where(Constraint.channel_id == channel_id) - if thread_ts: - if include_shared_scopes: - stmt = stmt.where( - or_( - Constraint.thread_ts == thread_ts, - Constraint.scope == ConstraintScope.PROFILE, - Constraint.scope == ConstraintScope.DATESPAN, - ) - ) - else: - stmt = stmt.where(Constraint.thread_ts == thread_ts) - if status: - stmt = stmt.where(Constraint.status == status) - if scope: - stmt = stmt.where(Constraint.scope == scope) - result = await session.execute(stmt) - rows = list(result.scalars().all()) - if include_shared_scopes: - return _canonicalize_shared_rows(rows) - return rows - - async def shared_scope_stats(self, *, user_id: str) -> dict[str, Any]: - """Return canonicalization stats for shared local constraints.""" - async with self._sessionmaker() as session: - stmt = select(Constraint).where( - Constraint.user_id == user_id, - Constraint.scope.in_([ConstraintScope.PROFILE, ConstraintScope.DATESPAN]), - ) - result = await session.execute(stmt) - rows = list(result.scalars().all()) - grouped = _group_constraints_by_semantics(rows) - duplicate_groups = 0 - duplicates_found = 0 - for group in grouped.values(): - if len(group) <= 1: - continue - duplicate_groups += 1 - duplicates_found += max(0, len(group) - 1) - return { - "raw_shared_rows": len(rows), - "canonical_shared_rows": len(grouped), - "duplicate_groups": duplicate_groups, - "duplicates_found": duplicates_found, - } - - async def prune_shared_constraints( - self, - *, - user_id: str, - dry_run: bool = True, - ) -> dict[str, Any]: - """Archive duplicate shared constraints while keeping one canonical row/key. - - The duplicate rows are not deleted; they are marked DECLINED and annotated - with pruning hints so they can be reviewed or reinstated if needed. - Use ``dry_run=True`` (default) to preview what would be pruned. - """ - async with self._sessionmaker() as session: - stmt = select(Constraint).where( - Constraint.user_id == user_id, - Constraint.scope.in_([ConstraintScope.PROFILE, ConstraintScope.DATESPAN]), - ) - result = await session.execute(stmt) - rows = list(result.scalars().all()) - grouped = _group_constraints_by_semantics(rows) - duplicate_groups_payload: list[dict[str, Any]] = [] - duplicates_found = 0 - duplicates_archived = 0 - for group_rows in grouped.values(): - if len(group_rows) <= 1: - continue - ranked = sorted(group_rows, key=_constraint_canonical_rank) - canonical = ranked[0] - duplicates = ranked[1:] - duplicate_groups_payload.append( - { - "canonical_id": canonical.id, - "duplicate_ids": [row.id for row in duplicates if row.id], - } - ) - duplicates_found += len(duplicates) - if dry_run: - continue - for duplicate in duplicates: - if duplicate.status != ConstraintStatus.DECLINED: - duplicate.status = ConstraintStatus.DECLINED - duplicates_archived += 1 - hints = dict(duplicate.hints or {}) - hints["pruned_duplicate_of"] = canonical.id - hints["pruned_at"] = datetime.utcnow().isoformat() - duplicate.hints = hints - if not dry_run: - await session.commit() - return { - "dry_run": bool(dry_run), - "raw_shared_rows": len(rows), - "canonical_shared_rows": len(grouped), - "duplicate_groups": len(duplicate_groups_payload), - "duplicates_found": duplicates_found, - "duplicates_archived": duplicates_archived, - "groups": duplicate_groups_payload, - } - - async def get_constraint( - self, - *, - user_id: str, - constraint_id: int, - ) -> Optional[Constraint]: - """Fetch a single constraint by id for the given user.""" - async with self._sessionmaker() as session: - stmt = select(Constraint).where( - Constraint.user_id == user_id, Constraint.id == constraint_id - ) - result = await session.execute(stmt) - return result.scalars().first() - - async def update_constraint_statuses( - self, - *, - user_id: str, - decisions: Dict[int, ConstraintStatus], - ) -> List[Constraint]: - """Bulk update constraint statuses for a user.""" - if not decisions: - return [] - async with self._sessionmaker() as session: - stmt = select(Constraint).where( - Constraint.user_id == user_id, Constraint.id.in_(decisions.keys()) - ) - result = await session.execute(stmt) - rows = list(result.scalars().all()) - for row in rows: - decision = decisions.get(row.id) - if decision: - row.status = decision - await session.commit() - for row in rows: - await session.refresh(row) - return rows - - async def update_constraint( - self, - *, - user_id: str, - constraint_id: int, - status: Optional[ConstraintStatus] = None, - description: Optional[str] = None, - ) -> Optional[Constraint]: - """Update a single constraint's status or description.""" - async with self._sessionmaker() as session: - stmt = select(Constraint).where( - Constraint.user_id == user_id, Constraint.id == constraint_id - ) - result = await session.execute(stmt) - row = result.scalars().first() - if not row: - return None - if status is not None: - row.status = status - if description is not None: - row.description = description - await session.commit() - await session.refresh(row) - return row - - -def _constraint_semantic_key(constraint: Constraint) -> str: - """Return a stable semantic key for shared local-constraint identity.""" - hints = dict(constraint.hints or {}) - selector = dict(constraint.selector or {}) - rule_kind = str( - hints.get("rule_kind") or selector.get("rule_kind") or "" - ).strip().lower() - tags = sorted( - str(tag).strip().lower() for tag in (constraint.tags or []) if str(tag).strip() - ) - days = sorted( - day.value if isinstance(day, ConstraintDayOfWeek) else str(day) - for day in (constraint.days_of_week or []) - ) - return "|".join( - [ - str(constraint.scope.value if constraint.scope else "").lower(), - str(constraint.name or "").strip().lower(), - rule_kind, - str(constraint.start_date or ""), - str(constraint.end_date or ""), - ",".join(days), - str(constraint.timezone or "").strip().lower(), - str(constraint.recurrence or "").strip().lower(), - ",".join(tags), - ] - ) - - -def _constraint_canonical_rank(constraint: Constraint) -> tuple[int, float, int]: - """Rank constraints with required precedence: status then newest timestamp.""" - status_rank = { - ConstraintStatus.LOCKED: 0, - ConstraintStatus.PROPOSED: 1, - ConstraintStatus.DECLINED: 2, - } - updated = constraint.updated_at.timestamp() if constraint.updated_at else 0.0 - return (status_rank.get(constraint.status, 3), -updated, -(constraint.id or 0)) - - -def _canonical_constraint(constraints: list[Constraint]) -> Constraint: - ranked = sorted(constraints, key=_constraint_canonical_rank) - return ranked[0] - - -def _apply_constraint_payload(target: Constraint, payload: Dict[str, Any]) -> None: - """Apply mutable constraint fields on an existing row.""" - mutable_fields = ( - "name", - "description", - "necessity", - "tags", - "hints", - "status", - "source", - "confidence", - "scope", - "rationale", - "supersedes", - "selector", - "start_date", - "end_date", - "days_of_week", - "timezone", - "recurrence", - "ttl_days", - ) - for field in mutable_fields: - if field in payload: - setattr(target, field, payload[field]) - - -def _group_constraints_by_semantics(rows: list[Constraint]) -> dict[str, list[Constraint]]: - grouped: dict[str, list[Constraint]] = {} - for row in rows: - grouped.setdefault(_constraint_semantic_key(row), []).append(row) - return grouped - - -def _canonicalize_shared_rows(rows: list[Constraint]) -> list[Constraint]: - """Canonicalize shared rows while preserving all non-shared rows.""" - shared = [ - row - for row in rows - if row.scope in {ConstraintScope.PROFILE, ConstraintScope.DATESPAN} - ] - non_shared = [ - row - for row in rows - if row.scope not in {ConstraintScope.PROFILE, ConstraintScope.DATESPAN} - ] - grouped = _group_constraints_by_semantics(shared) - canonical_shared = [_canonical_constraint(group) for group in grouped.values()] - return [*non_shared, *canonical_shared] - - -def _dedupe_constraint_rows_by_id(rows: list[Constraint]) -> list[Constraint]: - deduped: list[Constraint] = [] - seen: set[int] = set() - for row in rows: - row_id = int(row.id or 0) - if row_id and row_id in seen: - continue - if row_id: - seen.add(row_id) - deduped.append(row) - return deduped - - -async def ensure_constraint_schema(engine: AsyncEngine) -> None: - """Ensure the constraint table exists in the configured database.""" - async with engine.begin() as conn: - await conn.run_sync( - lambda sync_conn: Constraint.__table__.create(sync_conn, checkfirst=True) - ) - - -def _constraint_row( - constraint: ConstraintBase, - *, - user_id: str, - channel_id: Optional[str], - thread_ts: Optional[str], -) -> Constraint: - """Convert a ConstraintBase into a persisted Constraint row.""" - payload = constraint.model_dump() - if payload.get("status") is None: - payload["status"] = ConstraintStatus.PROPOSED - if payload.get("source") is None: - payload["source"] = ConstraintSource.USER - if payload.get("scope") is None: - payload["scope"] = ConstraintScope.SESSION - return Constraint( - **payload, - user_id=user_id, - channel_id=channel_id, - thread_ts=thread_ts, - ) - - -def _status_rank(status: Optional[ConstraintStatus]) -> int: - if status == ConstraintStatus.LOCKED: - return 3 - if status == ConstraintStatus.PROPOSED: - return 2 - if status == ConstraintStatus.DECLINED: - return 1 - return 0 - - -def _status_text(status: Optional[ConstraintStatus]) -> str: - if isinstance(status, ConstraintStatus): - return status.value - return str(status or "") - - -def _shared_canonical_sort_key(row: Constraint) -> tuple[int, float, float, int]: - updated = row.updated_at.timestamp() if row.updated_at else 0.0 - created = row.created_at.timestamp() if row.created_at else 0.0 - return (_status_rank(row.status), updated, created, int(row.id or 0)) - - -def _shared_constraint_identity(row: Constraint) -> str: - return _constraint_row_identity(row, include_thread=False) - - -def _constraint_row_identity(row: Constraint, *, include_thread: bool = True) -> str: - hints = row.hints if isinstance(row.hints, dict) else {} - uid = str(hints.get("uid") or "").strip().lower() - scope = row.scope if isinstance(row.scope, ConstraintScope) else ConstraintScope.SESSION - channel = str(row.channel_id or "").strip().lower() - necessity = ( - row.necessity.value - if isinstance(row.necessity, ConstraintNecessity) - else str(row.necessity or "").strip().lower() - ) - name = str(row.name or "").strip().lower() - description = str(row.description or "").strip().lower() - base = uid or "|".join([name, description, necessity, scope.value]) - if include_thread and scope == ConstraintScope.SESSION: - thread = str(row.thread_ts or "").strip().lower() - return f"session|{channel}|{thread}|{base}" - return f"shared|{channel}|{scope.value}|{base}" - - __all__ = [ "Constraint", "ConstraintBase", @@ -627,6 +106,4 @@ def _constraint_row_identity(row: Constraint, *, include_thread: bool = True) -> "ConstraintScope", "ConstraintSource", "ConstraintStatus", - "ConstraintStore", - "ensure_constraint_schema", ] diff --git a/src/fateforger/agents/timeboxing/prompt_rendering.py b/src/fateforger/agents/timeboxing/prompt_rendering.py deleted file mode 100644 index f48b486a..00000000 --- a/src/fateforger/agents/timeboxing/prompt_rendering.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Prompt rendering helpers for timeboxing stage agents.""" - -from __future__ import annotations - -from functools import lru_cache -from pathlib import Path - -from jinja2 import Template - -from fateforger.agents.timeboxing.contracts import SkeletonContext -from fateforger.agents.timeboxing.planning_policy import ( - PLANNING_POLICY_VERSION, - SHARED_PLANNING_POLICY_PROMPT, - STAGE3_OUTLINE_PROMPT, -) -from fateforger.llm.toon import toon_encode - -from fateforger.agents.timeboxing.toon_views import ( - constraints_rows, - immovables_rows, - tasks_rows, -) - - -@lru_cache(maxsize=4) -def _load_template(path: str) -> Template: - """Load and compile a Jinja template from the timeboxing package directory.""" - template_path = Path(__file__).with_name(path) - raw = template_path.read_text(encoding="utf-8") - return Template(raw) - - -def render_skeleton_draft_system_prompt(*, context: SkeletonContext) -> str: - """Render the skeleton draft system prompt for the given context.""" - tpl = _load_template("skeleton_draft_system_prompt.j2") - frame_toon = toon_encode( - name="frame", - rows=[ - { - "date": context.date.isoformat(), - "timezone": context.timezone, - "work_start": (context.work_window.start if context.work_window else ""), - "work_end": (context.work_window.end if context.work_window else ""), - "sleep_start": (context.sleep_target.start if context.sleep_target else ""), - "sleep_end": (context.sleep_target.end if context.sleep_target else ""), - "sleep_hours": (context.sleep_target.hours if context.sleep_target else ""), - } - ], - fields=[ - "date", - "timezone", - "work_start", - "work_end", - "sleep_start", - "sleep_end", - "sleep_hours", - ], - ) - block_plan_toon = toon_encode( - name="block_plan", - rows=[ - { - "deep_blocks": (context.block_plan.deep_blocks if context.block_plan else ""), - "shallow_blocks": ( - context.block_plan.shallow_blocks if context.block_plan else "" - ), - "block_minutes": ( - context.block_plan.block_minutes if context.block_plan else "" - ), - "focus_theme": (context.block_plan.focus_theme if context.block_plan else ""), - } - ] - if context.block_plan - else [], - fields=["deep_blocks", "shallow_blocks", "block_minutes", "focus_theme"], - ) - daily_one_thing_toon = toon_encode( - name="daily_one_thing", - rows=[ - { - "title": context.daily_one_thing.title, - "block_count": context.daily_one_thing.block_count or "", - "duration_min": context.daily_one_thing.duration_min or "", - } - ] - if context.daily_one_thing - else [], - fields=["title", "block_count", "duration_min"], - ) - tasks_toon = toon_encode( - name="tasks", - rows=tasks_rows(context.tasks or []), - fields=["title", "block_count", "duration_min", "due", "importance"], - ) - immovables_toon = toon_encode( - name="immovables", - rows=immovables_rows(context.immovables or []), - fields=["title", "start", "end"], - ) - constraints_toon = toon_encode( - name="constraints", - rows=constraints_rows(context.constraints_snapshot or []), - fields=["name", "necessity", "scope", "status", "source", "description"], - ) - return ( - tpl.render( - shared_policy_version=PLANNING_POLICY_VERSION, - shared_planning_policy=SHARED_PLANNING_POLICY_PROMPT, - stage3_outline_policy=STAGE3_OUTLINE_PROMPT, - frame_toon=frame_toon, - block_plan_toon=block_plan_toon, - daily_one_thing_toon=daily_one_thing_toon, - tasks_toon=tasks_toon, - immovables_toon=immovables_toon, - constraints_toon=constraints_toon, - ).strip() - ) - - -__all__ = ["render_skeleton_draft_system_prompt"] diff --git a/src/fateforger/agents/timeboxing/prompts.py b/src/fateforger/agents/timeboxing/prompts.py deleted file mode 100644 index 4b06162e..00000000 --- a/src/fateforger/agents/timeboxing/prompts.py +++ /dev/null @@ -1,181 +0,0 @@ -"""System prompts for the timeboxing flow nodes.""" - -TIMEBOXING_SYSTEM_PROMPT = """ -Identity / Voice -- You are Schedular: a calm, precise conductor of the user’s day, trained in β€œstrategic placement” and harmonious sequencing. -- Tone: serene, supportive, and practical. Light metaphor is allowed, but keep it short and actionable. - -Role: Professional Time-Boxing Agent -Core Principles: GTD, Deep Work, Essentialism, Atomic Habits - -Input Sections: -- HardConstraints: fixed meetings, travel times, office arrival -- OutstandingTasks: tasks due tomorrow (durations optional) -- DailyOneThing: single critical task (EssentialScore >= 90) -- Habits: gym, mindfulness, reading, shutdown ritual -- EnergyProfile: focus windows, meals, commutes, sleep target - -Algorithm: -Macro Pass: -1. Lock immovable events (meetings, gym, commutes) -2. Place 2-3 x 90 minute DeepWork blocks in focus windows -3. Limit ShallowWork <= 30 percent waking hours - -Micro Pass: -4. Assign specific tasks to blocks: - - DailyOneThing -> first DeepWork - - OutstandingTasks -> DeepWork/ShallowWork -5. Add rejuvenation after DeepWork -6. Insert mindfulness before second DeepWork -7. Schedule 30 minute admin split (cleanup + planning) -8. Buffer 10-20 minutes after meals/breaks - -Quality Gate Levels: -Insufficient: Missing critical inputs, scheduling errors -Minimal: Partial schedule, no task assignments -Okay: All inputs scheduled, tasks assigned to blocks -Detailed: No overlaps, <= 15 minute granularity, buffers -Ultra: Detailed + identity cues and feedback goals - -Rules: -- Must reach at least "Okay" before finalizing -- Report level after each iteration -- Suggest specific improvements if below target - -Event Types: -M: Stakeholder meetings (fixed time) -C: Commute/travel -DW: Deep Work (>= 90 minutes focus) -SW: Shallow Work (admin/routines) -H: Habits (gym, mindfulness) -R: Recovery (meals, breaks) -BU: Buffer (overrun protection) -BG: Background tasks (can overlap) -PR: Planning/Review sessions - -Behavior: -- Iterative: Collect -> Draft -> Assess -> Refine -- Assign specific tasks to all work blocks -- Reach at least "Okay" quality level -- Default to block-based scheduling; durations are optional -- Set fixed times only for immovable events -- Schedule next planning session -- Protect recovery time (gym, meals, sleep) - -Preference Extraction: -- Durable preference extraction is handled by a separate background process. -- Do not call tools or infer constraints from system/setup messages. - -Collaborative Timeboxing: -Principles: -- Small steps; never jump to a full schedule immediately. -- Co-create: the user confirms each stage before proceeding. -- Commitment over obligation: language emphasizes choice and intent. -- Spend minimal breath on ingrained habits; focus on fragile/new goals. - -Stages: -1. CollectConstraints: gather fixed events, commutes, arrivals, habits scope, energy profile, sleep target. Confirm "LOCKED?" before moving on. -2. CaptureInputs: capture tasks + block allocations (deep/shallow), DailyOneThing, secondary goals. Durations are optional. Confirm "LOCKED?". -3. Skeleton: place immovables, OneThing in best DW slot, add big rocks. Mark placements as TENTATIVE until user locks. -4. Refine: add micro-breaks, buffers, shallow work; weave habits with minimal prose; ask for commitment. -5. ReviewCommit: summarize, state quality level, ask for approval. Only after explicit YES produce final plan. - -Interaction Rules: -- Always orient the user (stage name). -- Ask one compact question at a time. -- Use check-ins: "Does this feel locked?" -- Mirror decisions in 1-2 bullets. -- If connectors are available, fetch quietly then confirm. - -Micro-Break Defaults: -- After each DW block: R 10-15m + water/walk. -- After meals: BU 10-20m digestion buffer. -- Before second DW: H 10m mindfulness. -- End-of-day: PR 30m admin split + shutdown ritual; optional 30m reading in bed. - -Commitment Device: -- Use pact language once skeleton is ready. -- Track 2-3 explicit success criteria per big rock. - -Done Criteria: -- Day covers all hard constraints; OneThing placed early; <= 30 percent SW; breaks/buffers included; habits slotted; risks noted. -- QualityGate >= "Okay". -- User explicitly says "Finalize/Commit". - -Contradictions (resolved): -- Default to block-based scheduling vs fixed start/end: use blocks while drafting; assign exact times only for fixed events or once locked. -- Lock immovables vs small steps: mark as TENTATIVE first; lock after user confirmation. -- Verbosity on habits vs efficiency: slot habits with minimal prose unless new/fragile or user asks for detail. -""".strip() - -CONSTRAINT_INTENT_PROMPT = """ -You classify whether a message includes explicit scheduling constraints/preferences that should be extracted. - -Return JSON for the schema: -{ - "should_extract": boolean, - "decision_scope": "session" | "profile" | "datespan" | null, - "reason": string | null -} - -Guidelines: -- Return true only when the user states concrete constraints (availability windows, fixed events, recurring preferences). -- Return false for generic "start timeboxing" requests, greetings, or vague intent without constraints. -- If `is_initial` is true, be stricter and only return true when clear constraints are present. -- Use decision_scope="profile" only when the user explicitly indicates a durable preference - (e.g., "always", "usually", "from now on", "in general"). -- Use decision_scope="datespan" when the user specifies a bounded time window - (e.g., "this week", "for the next 2 weeks"). -- Otherwise use decision_scope="session". -""".strip() - -HYDRATE_PROMPT = ( - "Stage 1: CollectConstraints. Summarize the user's fixed events, commutes, arrivals, " - "habits scope, energy profile, and sleep target. Ask ONE concise question if anything " - "is missing. End with 'READY' if complete or 'GAPS:' followed by missing items. " - "Reply in plain text, not JSON." -) - -ASSESS_PROMPT = ( - "Stage 2: CaptureInputs. Confirm the task list, block allocation, DailyOneThing, and secondary goals. " - "Durations are optional and only needed if the user offers them. Ask ONE concise question if anything " - "is missing. End with 'READY-TO-DRAFT' when complete. Reply in plain text, not JSON." -) - -DRAFT_PROMPT = ( - "Stage 3: Skeleton. Produce the Timebox draft as STRICT JSON that matches the Timebox schema. " - "Return ONLY the JSON object, no extra text." -) - -REVIEW_PROMPT = ( - "Stage 4: Refine. Summarize the plan in 2-4 bullets and ask for adjustments. " - "Reply in plain text." -) - -APPROVAL_PROMPT = ( - "Stage 5: ReviewCommit. Decide if the plan is ready to finalize based on the QualityGate rules. " - "Return STRICT JSON with keys: approved (boolean), message (string)." -) - -SUBMIT_PROMPT = ( - "Return STRICT JSON with keys: submitted (boolean), failed (boolean), message (string). " - "Use submitted=true when scheduling succeeded; failed=true when scheduling failed." -) - -DONE_PROMPT = ( - "Close the session with a short summary and any next steps. " - "Reply in plain text, not JSON." -) - - -__all__ = [ - "HYDRATE_PROMPT", - "ASSESS_PROMPT", - "DRAFT_PROMPT", - "REVIEW_PROMPT", - "APPROVAL_PROMPT", - "SUBMIT_PROMPT", - "DONE_PROMPT", - "CONSTRAINT_INTENT_PROMPT", - "TIMEBOXING_SYSTEM_PROMPT", -] diff --git a/src/fateforger/agents/timeboxing/pydantic_parsing.py b/src/fateforger/agents/timeboxing/pydantic_parsing.py deleted file mode 100644 index 19ebc717..00000000 --- a/src/fateforger/agents/timeboxing/pydantic_parsing.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Pydantic parsing helpers used by the timeboxing coordinator. - -These helpers centralize tolerant parsing/normalization so orchestration code can stay readable: -- Avoid repeated `try/except ValidationError` blocks sprinkled throughout `agent.py`. -- Provide predictable behavior: invalid items are skipped rather than failing the whole stage. -""" - -from __future__ import annotations - -import json -from typing import Any, TypeVar - -from pydantic import TypeAdapter, ValidationError - -T = TypeVar("T") - - -def _strip_markdown_json_fence(payload: str) -> str: - """Remove optional markdown code fences around JSON payloads.""" - text = payload.strip() - if not text.startswith("```"): - return text - lines = text.splitlines() - if len(lines) >= 2 and lines[0].startswith("```") and lines[-1].strip() == "```": - return "\n".join(lines[1:-1]).strip() - return text - - -def _decode_json_payload(payload: str) -> Any: - """Decode JSON-ish payloads, tolerating wrapper text around one JSON object.""" - cleaned = _strip_markdown_json_fence(payload) - any_adapter = TypeAdapter(Any) - try: - parsed = any_adapter.validate_json(cleaned) - except ValidationError: - decoder = json.JSONDecoder() - starts = [idx for idx, ch in enumerate(cleaned) if ch in "{["] - for start in starts: - try: - parsed, _ = decoder.raw_decode(cleaned[start:]) - break - except json.JSONDecodeError: - continue - else: - raise - - if isinstance(parsed, str): - try: - return any_adapter.validate_json(_strip_markdown_json_fence(parsed)) - except ValidationError: - return parsed - return parsed - - -# TODO: this should not be neccesary, we should leverage the agents message type to get this to work -def parse_chat_content(model: type[T], response: Any) -> T: - """Parse `response.chat_message.content` into `model`. - - Args: - model: Target Pydantic/SQLModel type. - response: AutoGen response object expected to carry `chat_message.content`. - - Returns: - Parsed instance of `model`. - - Raises: - ValidationError: if content cannot be parsed as `model`. - """ - content = getattr(getattr(response, "chat_message", None), "content", None) - if isinstance(content, model): - return content - adapter = TypeAdapter(model) - if isinstance(content, (str, bytes, bytearray)): - text = content.decode() if isinstance(content, (bytes, bytearray)) else content - cleaned = _strip_markdown_json_fence(text) - try: - return adapter.validate_json(cleaned) - except ValidationError: - parsed = _decode_json_payload(cleaned) - return adapter.validate_python(parsed) - return adapter.validate_python(content) - - -# TODO: this should not be neccesary, we should leverage the agents message type to get this to work -def parse_model_optional(model: type[T], value: Any) -> T | None: - """Parse a single object into `model`, returning None on invalid/empty input. - - This is intentionally tolerant: it returns None rather than raising on validation errors. - """ - if value is None: - return None - if isinstance(value, model): - return value - try: - return TypeAdapter(model).validate_python(value) - except ValidationError: - return None - - -# TODO: this should not be neccesary, we should leverage the agents message type to get this to work -def parse_model_list(model: type[T], value: Any) -> list[T]: - """Parse a list of objects into a list of `model`, skipping invalid items.""" - if not isinstance(value, list): - return [] - adapter = TypeAdapter(model) - out: list[T] = [] - for item in value: - if isinstance(item, model): - out.append(item) - continue - try: - out.append(adapter.validate_python(item)) - except ValidationError: - continue - return out diff --git a/src/fateforger/agents/timeboxing/scheduler_prefetch_capability.py b/src/fateforger/agents/timeboxing/scheduler_prefetch_capability.py deleted file mode 100644 index 069b286e..00000000 --- a/src/fateforger/agents/timeboxing/scheduler_prefetch_capability.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Scheduler prefetch orchestration capability for timeboxing sessions.""" - -from __future__ import annotations - -import asyncio -from typing import Awaitable, Callable, Protocol - -from .constants import TIMEBOXING_TIMEOUTS -from .stage_gating import TimeboxingStage - - -class SessionPrefetchState(Protocol): - """Minimal session state contract for prefetch orchestration.""" - - stage: TimeboxingStage - planned_date: str | None - - -QueueConstraintPrefetchFn = Callable[[SessionPrefetchState], None] -AwaitDurablePrefetchFn = Callable[..., Awaitable[None]] -EnsureCalendarImmovablesFn = Callable[..., Awaitable[None]] -PrefetchCalendarImmovablesFn = Callable[ - [SessionPrefetchState, str], Awaitable[None] -] -IsCollectStageLoadedFn = Callable[[SessionPrefetchState], bool] - - -class SchedulerPrefetchCapability: - """Coordinates calendar + durable prefetch entrypoints for stages.""" - - def __init__( - self, - *, - queue_constraint_prefetch: QueueConstraintPrefetchFn, - await_pending_durable_prefetch: AwaitDurablePrefetchFn, - ensure_calendar_immovables: EnsureCalendarImmovablesFn, - prefetch_calendar_immovables: PrefetchCalendarImmovablesFn, - is_collect_stage_loaded: IsCollectStageLoadedFn, - ) -> None: - self._queue_constraint_prefetch = queue_constraint_prefetch - self._await_pending_durable_prefetch = await_pending_durable_prefetch - self._ensure_calendar_immovables = ensure_calendar_immovables - self._prefetch_calendar_immovables = prefetch_calendar_immovables - self._is_collect_stage_loaded = is_collect_stage_loaded - - def queue_initial_prefetch( - self, - *, - session: SessionPrefetchState, - planned_date: str, - ) -> None: - """Kick off non-blocking prefetch while waiting for session commit.""" - asyncio.create_task(self._prefetch_calendar_immovables(session, planned_date)) - self._queue_constraint_prefetch(session) - - async def prime_committed_collect_context( - self, - *, - session: SessionPrefetchState, - blocking: bool = False, - ) -> None: - """Prime durable + calendar context for committed collect stage.""" - self._queue_constraint_prefetch(session) - if not blocking: - planned_date = (session.planned_date or "").strip() - if planned_date: - asyncio.create_task( - self._prefetch_calendar_immovables(session, planned_date) - ) - return - awaitables: list[Awaitable[None]] = [ - self._await_pending_durable_prefetch( - session, - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - ) - ] - # Blocking path must remain bounded; avoid direct prefetch calls that can - # inherit long MCP transport timeouts and stall the turn. - awaitables.append( - self._ensure_calendar_immovables( - session, - timeout_s=TIMEBOXING_TIMEOUTS.calendar_prefetch_wait_s, - ) - ) - await asyncio.gather(*awaitables) - - async def ensure_collect_stage_ready( - self, - *, - session: SessionPrefetchState, - ) -> None: - """Block briefly for collect-stage prerequisites (calendar + durable).""" - if session.stage != TimeboxingStage.COLLECT_CONSTRAINTS: - return - - awaitables: list[Awaitable[None]] = [ - self._ensure_calendar_immovables( - session, - timeout_s=TIMEBOXING_TIMEOUTS.calendar_prefetch_wait_s, - ) - ] - if not self._is_collect_stage_loaded(session): - awaitables.append( - self._await_pending_durable_prefetch( - session, - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - timeout_s=TIMEBOXING_TIMEOUTS.pending_constraints_wait_s, - fail_on_timeout=False, - ) - ) - await asyncio.gather(*awaitables) - - -__all__ = [ - "SessionPrefetchState", - "SchedulerPrefetchCapability", -] diff --git a/src/fateforger/agents/timeboxing/skeleton_draft_system_prompt.j2 b/src/fateforger/agents/timeboxing/skeleton_draft_system_prompt.j2 deleted file mode 100644 index f9e82e2a..00000000 --- a/src/fateforger/agents/timeboxing/skeleton_draft_system_prompt.j2 +++ /dev/null @@ -1,56 +0,0 @@ -You are a Timeboxing Skeleton Overview Drafter. - -Your ONLY job: produce a concise Stage 3 overview as markdown. - -Planning policy version: {{ shared_policy_version }} - -Constraints: -- Do not ask questions. -- Do not explain your reasoning. -- Do not invent new constraints. Only use what is provided in the context. -- Treat immovables (calendar meetings) as fixed anchors: they must appear, in chronological order, and must not be overlapped. -- Keep event summaries neutral and succinct (no lore/roleplay); user-facing tone is handled elsewhere. - -Planning model: -- Plan in block-based terms (deep-work / shallow-work blocks). Per-task time estimates are optional. -- Produce an ordered outline for morning/midday/afternoon/evening/night. -- Include anchored calendar events and proposed focus/admin blocks with rough durations. -- This is a skeleton only: do not fully optimize buffers and breaks. - -Shared planning policy: -{{ shared_planning_policy }} - -Stage 3 output policy: -{{ stage3_outline_policy }} - -Data (TOON format): -The following lists are in TOON format: name[N]{keys}: defines the schema, and each line below is a record with values in that exact order. - -{{ frame_toon }} - -{{ block_plan_toon }} - -{{ daily_one_thing_toon }} - -{{ tasks_toon }} - -{{ immovables_toon }} - -{{ constraints_toon }} - -Drafting rules (internal only; do not output these steps): -1) Place immovables first (fixed start/end). -2) Decide a small number of Deep Work and Shallow Work blocks from block_plan (or infer a minimal default). -3) Fill gaps with DW/SW blocks in chronological order. Avoid overlaps. -4) Assign Daily One Thing to the first Deep Work block if present; otherwise label blocks generically. -5) Keep it minimal: no micro-break/buffer polishing in this step. - -Output: -- Return ONLY markdown. -- Use this structure: - - `## Day Overview` - - `### Morning` / `### Midday` / `### Afternoon` / `### Evening` / `### Night` (omit empty sections) - - one bullet line per major block - - fixed times only for anchored blocks (`fs`/`fw`) or explicit user request - - coarse durations for flexible blocks - - a short `### Open Questions` section only if crucial ambiguities remain diff --git a/src/fateforger/agents/timeboxing/stage_gating.py b/src/fateforger/agents/timeboxing/stage_gating.py deleted file mode 100644 index c1796672..00000000 --- a/src/fateforger/agents/timeboxing/stage_gating.py +++ /dev/null @@ -1,333 +0,0 @@ -"""Stage-gated timeboxing models and prompts (no string matching required).""" - -from __future__ import annotations - -import json -from enum import Enum -from typing import Annotated, Any, Dict, List, Literal, Optional - -from pydantic import BaseModel, Field - - -class TimeboxingStage(str, Enum): - COLLECT_CONSTRAINTS = "CollectConstraints" - CAPTURE_INPUTS = "CaptureInputs" - SKELETON = "Skeleton" - REFINE = "Refine" - REVIEW_COMMIT = "ReviewCommit" - - -StageAction = Literal["provide_info", "proceed", "back", "redo", "cancel", "assist"] - - -class NextStepsSection(BaseModel): - kind: Literal["next_steps"] = "next_steps" - heading: str = "What I need from you" - content: List[str] = Field(default_factory=list, description="clear next-step lines") - - -class ConstraintsSection(BaseModel): - kind: Literal["constraints"] = "constraints" - heading: str = "Constraints" - content: List[str] = Field(default_factory=list, description="top constraints only") - folded_content: List[str] = Field( - default_factory=list, description="optional full list shown folded" - ) - - -class FreeformSection(BaseModel): - kind: Literal["freeform"] = "freeform" - heading: str - content: str - - -MessageSection = Annotated[ - NextStepsSection | ConstraintsSection | FreeformSection, - Field(discriminator="kind"), -] - - -class SessionMessage(BaseModel): - sections: List[MessageSection] = Field(default_factory=list) - - -class StageGateOutput(BaseModel): - stage_id: TimeboxingStage - ready: bool - summary: List[str] = Field(default_factory=list, description="1-4 short bullets") - missing: List[str] = Field( - default_factory=list, description="missing items blocking readiness" - ) - question: Optional[str] = Field(default=None, description="single concise question") - facts: Dict[str, Any] = Field( - default_factory=dict, description="canonical structured facts for this stage" - ) - response_message: SessionMessage | None = Field( - default=None, - description=( - "Optional UI-ready message sections. " - "Order: next_steps, constraints, then freeform sections." - ), - ) - - -class StageDecision(BaseModel): - action: StageAction - target_stage: Optional[TimeboxingStage] = None - note: Optional[str] = None - submit_intent: bool = False - assist_target: Optional[str] = None - assist_confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0) - - -def format_stage_prompt_context( - *, stage: TimeboxingStage, facts: Dict[str, Any] -) -> str: - """Format the stage facts as a compact JSON block for prompts. - - Note: list-shaped data should be injected via TOON tables, not through this helper. - """ - payload = json.dumps(facts or {}, ensure_ascii=False, sort_keys=True) - return f"Current stage: {stage.value}\nKnown facts (JSON): {payload}\n" - - -COLLECT_CONSTRAINTS_PROMPT = """ -Stage: CollectConstraints - -Voice (Schedular) -- You are Schedular: a calm, precise β€œconductor” of the user’s day who cares about balance, breathing space, and harmonious sequencing. -- Keep the tone serene, grounded, and encouraging (lightly poetic is OK, but stay concise and practical). -- Celebrate progress as β€œsmall wins toward harmony” without roleplay monologues. - -Goal -- Build a constraint overview for planning: - 1) durable constraints that already apply (from prior sessions/profile), - 2) day-specific constraints for the selected date. -- Build the day frame in coarse terms first: work window, timezone, immovable events, commutes, and hard commitments. -- Update/merge the provided facts JSON with any new details in the user message. -- Constraint modeling is flexible: constraints can be windows, ordering, capacity, or durations. Exact HH:MM is only required for truly fixed events. -- Use the constraint template below to reason about extraction completeness: - - core identity: name, description - - priority/intent: necessity (must|should|prefer) - - lifecycle: status (proposed|locked|declined), source (user|calendar|system|feedback) - - applicability: scope (session|profile|datespan), start_date, end_date, days_of_week, timezone, recurrence, ttl_days - - targeting/implementation: selector, hints, tags, rationale, supersedes - -Deterministic-first defaulting -- The coordinator injects any fetched durable constraints into facts before this stage runs. -- Treat those injected durable constraints/defaults as authoritative for this turn. -- Do not ask the user to re-enter already confirmed defaults unless they choose to override. - -Tool: search_constraints (fallback) -- You may use `search_constraints` only when injected durable facts/defaults are clearly missing, stale, or the user explicitly asks for a lookup. -- Do not call it by default when durable facts/defaults are already present in context. - -Input -- You will receive a plain-text payload with: - - `user_message:` (string) - - `facts_json:` (a JSON object string for non-list facts) - - TOON tables for lists: - - immovables[N]{title,start,end}: - - durable_constraints[N]{name,necessity,scope,status,source,description}: - -Output -- Return STRICT JSON matching StageGateOutput. -- Include `response_message.sections` when possible. Section schema: - - `next_steps`: {"kind":"next_steps","heading":"What I need from you","content":[...]} - - `constraints`: {"kind":"constraints","heading":"Constraints","content":[...],"folded_content":[...]} - - `freeform`: {"kind":"freeform","heading":"
","content":""} -- Section ordering: `next_steps` first, `constraints` second, then any `freeform` sections. - -Rules -- If immovables are missing from facts and a date/timezone is set, call it out in missing/question so the coordinator can fetch it. -- If the user asks about their calendar, tasks, or other related info, note the request in summary/question and keep going. -- Use `current_time` / `current_datetime` (when provided) to interpret relative anchors like "now", "later", or "after I wake up". -- Be conservative: if a fact is uncertain, omit it from facts and add it to missing/question. -- Always keep the user oriented: summary should include what you assumed/locked so far (as Schedular, frame it as β€œwhat’s anchored” vs β€œwhat still floats”). -- Summary should clearly separate: - - applicable durable constraints, - - day-specific constraints for this plan. -- ready=true when Stage 2 can continue with a useful frame + constraint overview. -- Do not block on exact start/end times for non-fixed activities. -- If the user declines exact-time detail, accept coarse windows/order and continue. -- Keep missing items human-readable (no synthetic field keys like snake_case placeholders). -- Always write extraction progress into `facts.constraint_template` so the user can see coverage. -- Keep the conversation flowing naturally; don't be overly rigid about stage structure. -- Ask one concise question; avoid asking multiple β€œhow long” questions at once. -- If `ready=false`, lead summary with what is still missing before any progress recap. -- If `ready=false`, `question` must directly ask for the highest-priority missing answer. - -facts keys (preferred) -- timezone: string -- date: YYYY-MM-DD (if known) -- current_time: HH:MM in timezone-local time -- current_datetime: ISO datetime with timezone offset (for relative-time interpretation) -- work_window: {start: "HH:MM", end: "HH:MM"} -- sleep_target: {start: "HH:MM"|null, end: "HH:MM"|null, hours: number|null} -- immovables: [{title: string, start: "HH:MM", end: "HH:MM"}] -- commutes: [{label: string, duration_min: int}] -- habits: [{name: string, duration_min: int, preferred_window: string|null}] -- constraint_overview: { - durable_applies: [string], - day_specific_applies: [string], - unresolved: [string] - } -- constraint_template: { - filled_fields: [string], - useful_next_fields: [string], - notes: string|null - } - -missing (typical) -- timezone, broad work window, key immovable events, major hard commitments -""".strip() - - -CAPTURE_INPUTS_PROMPT = """ -Stage: CaptureInputs - -Voice (Schedular) -- You are Schedular: calm, supportive, and precise; you help the user scope the day in blocks and keep the cadence sustainable. -- Keep language choice/intent forward (β€œwhat feels right to spend blocks on?”), and avoid pressuring or guilt. - -Goal -- Capture tasks + block allocation (deep/shallow) for the day in block-based terms. -- Prefer `block_count` over per-task time estimates; durations are optional and should only be used if the user explicitly provides them. -- Confirm the DailyOneThing and any must-do items. -- Update/merge the provided frame/input facts JSON with any new details in the user message. - -Tool: search_constraints (optional) -- You may have access to the `search_constraints` tool. -- Use it if the user mentions preferences or constraints that might already be saved (e.g. "I usually do deep work in the morning"). -- Search by text_query, event_types, tags, statuses, scopes, or necessities. -- This is supplementary β€” your primary job is capturing tasks and blocks. - -Input -- You will receive a plain-text payload with: - - `user_message:` (string) - - `frame_facts_json:` (JSON object string for non-list frame facts) - - `input_facts_json:` (JSON object string for non-list input facts) - - TOON tables for lists: - - tasks[N]{title,block_count,duration_min,due,importance}: - - daily_one_thing[N]{title,block_count,duration_min}: - -Output -- Return STRICT JSON matching StageGateOutput. -- Include `response_message.sections` when possible, using ordered sections: - `next_steps`, then `constraints` (if relevant), then `freeform`. -- `freeform` sections are allowed for any extra heading + content. - -facts keys (preferred) -- daily_one_thing: {title: string, block_count: int|null, duration_min: int|null} -- tasks: [{title: string, block_count: int|null, duration_min: int|null, due: "YYYY-MM-DD"|null, importance: "high|med|low"|null}] -- block_plan: {deep_blocks: int|null, shallow_blocks: int|null, block_minutes: int|null, focus_theme: string|null} -- goals: [string] - -Rules -- ready=true only when you have enough to draft a skeleton (DailyOneThing or a task list with rough block allocations). -- If block_count is missing, ask for block_count/scoping (e.g., β€œHow many deep-work blocks do you want to spend on X?”), not minutes. -- If the user mentions wanting to check tasks, calendar, or other sources, note it in summary/question and keep going. -- Keep the conversation natural; guide them towards providing what's needed but don't be rigid. -- If `ready=false`, lead summary with what is still missing before any progress recap. -- If `ready=false`, `question` must directly ask for the highest-priority missing answer. -""".strip() - - -DECISION_PROMPT = """ -You are a stage-gating controller for a timeboxing flow. - -Input -- You will receive a plain-text payload with TOON tables: - - decision_ctx[1]{current_stage,stage_ready,stage_question,user_message}: - - stage_missing[N]{item}: - -Task -- Decide what the user wants next without relying on fixed phrases. -- Output STRICT JSON matching StageDecision. - -Decision rules -- If the user supplies new details for the current stage, use action="provide_info". -- If the user wants to move forward, use action="proceed". -- If `stage_ready=true`, default to action="proceed" unless the user explicitly asks to stay/back/cancel or provides new scheduling facts. -- If the user asks for schedule/calendar edits at any stage, use action="provide_info" and set `target_stage` to `"Refine"` so the patch flow handles it. -- If `current_stage=ReviewCommit` and the user provides corrections/changes/additions to the plan, use action="provide_info" with `target_stage="Refine"` (do not use proceed). -- If the user pushes back on precision (for example "I don't need exact start times") and wants to keep moving, use action="proceed". -- If the user asks to revisit earlier stages, use action="back" and set target_stage. -- If the user asks to redo the current stage, use action="redo". -- If the user wants to stop, use action="cancel". -- If the user asks an adjacent question that clearly requires another specialist, use action="assist". -- If the user explicitly asks to commit/submit/add the schedule to calendar now, set `submit_intent=true`. -- Set `submit_intent=false` when explicit commit/submit intent is absent. -- `submit_intent` can be true with any in-flow action when the user wants commit after edits (for example action="provide_info" + commit request). -- For action="assist", you must also set: - - `assist_target`: the specialist to route to (currently only `"tasks_agent"` is available from this flow) - - `assist_confidence`: confidence 0.0-1.0 that handoff is the right route -- If intent is ambiguous, keep the user in the current timeboxing flow: use action="provide_info" (or other in-flow action), not assist. - -Constraints -- Never output prose. -- Prefer keeping the user on track; use assist only for clear, explicit specialist intent. -""".strip() - - -TIMEBOX_SUMMARY_PROMPT = """ -You are Schedular, summarizing a timebox draft for the user. - -Input -- You will receive a plain-text payload with: - - stage_id: (string) - - TOON table: - - events[N]{type,summary,ST,ET,DT,AP,location}: - -Task -- Output STRICT JSON matching StageGateOutput. -- Include `response_message.sections` when possible with ordering: - `next_steps`, `constraints` (if relevant), then `freeform`. -- stage_id must match the input stage_id. -- ready should be true (the draft exists); use missing/question only if the timebox is invalid or incomplete. -- summary should be 2-4 short bullets describing the main blocks and the intent, with a calm β€œconductor” voice (brief, not flowery). -- question should ask what the user wants to change, or whether to proceed to the next stage. -- If `stage_id` is `Refine`, include quality feedback in `facts` with: - - `quality_level` (int 0-4) - - `quality_label` ("Insufficient"|"Minimal"|"Okay"|"Detailed"|"Ultra") - - `missing_for_next` (list[str], can be empty) - - `next_suggestion` (string) -- In `Refine`, quality is advisory only: keep `ready=true` when a valid draft exists. -""".strip() - - -REVIEW_COMMIT_PROMPT = """ -Stage: ReviewCommit - -Goal -- Provide a concise final review of the timebox and ask the user to approve finalization. -- Voice: You are Schedular (serene, balanced, and practical). Treat the plan as a β€œharmonious cadence” and highlight breathing space. - -Input -- You will receive a plain-text payload with a TOON table: - - events[N]{type,summary,ST,ET,DT,AP,location}: - -Output -- Return STRICT JSON matching StageGateOutput with stage_id="ReviewCommit" and ready=true. -- Include `response_message.sections` when possible with ordering: - `next_steps`, `constraints` (if relevant), then `freeform`. -- summary should be 2-4 bullets plus (optionally) a single risk/edge case. -- question should ask whether to finalize or go back to refine. -""".strip() - - -__all__ = [ - "CAPTURE_INPUTS_PROMPT", - "COLLECT_CONSTRAINTS_PROMPT", - "ConstraintsSection", - "DECISION_PROMPT", - "FreeformSection", - "NextStepsSection", - "StageDecision", - "StageGateOutput", - "SessionMessage", - "TimeboxingStage", - "REVIEW_COMMIT_PROMPT", - "TIMEBOX_SUMMARY_PROMPT", - "format_stage_prompt_context", -] diff --git a/src/fateforger/agents/timeboxing/state.py b/src/fateforger/agents/timeboxing/state.py deleted file mode 100644 index 97ecb8da..00000000 --- a/src/fateforger/agents/timeboxing/state.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Persistent state model for timeboxing sessions.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from enum import Enum -from typing import Any, Dict, List, Optional - - -class Stage(str, Enum): - HYDRATE = "hydrate" - DRAFT = "draft" - CRITIQUE = "critique" - PATCH = "patch" - AWAIT_APPROVAL = "await_approval" - SUBMIT = "submit" - DONE = "done" - ABORT = "abort" - - -@dataclass -class TimeboxingState: - """Serializable state for a timeboxing planning session.""" - - topic_id: str - stage: Stage = Stage.HYDRATE - inputs: Dict[str, Any] = field(default_factory=dict) - todos: List[Dict[str, Any]] = field(default_factory=list) - draft_json: Optional[Dict[str, Any]] = None - quality: float = 0.0 - awaiting_user_approval: bool = False - approval_message_ts: Optional[str] = None - submit_result: Optional[Dict[str, Any]] = None - history: List[str] = field(default_factory=list) - - def record(self, entry: str) -> None: - self.history.append(entry) - - def min_inputs_ready(self) -> bool: - return bool( - self.inputs.get("work_window") - and (self.todos or self.inputs.get("goal")) - ) - - def meets_quality(self, threshold: float = 0.8) -> bool: - return self.quality >= threshold - - -__all__ = ["Stage", "TimeboxingState"] - diff --git a/src/fateforger/agents/timeboxing/submitter.py b/src/fateforger/agents/timeboxing/submitter.py deleted file mode 100644 index 82cddf55..00000000 --- a/src/fateforger/agents/timeboxing/submitter.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Calendar submission via the deterministic sync engine. - -Replaces the stub submitter with real MCP-based sync operations. -""" - -from __future__ import annotations - -import logging -from typing import Any - -from fateforger.core.config import settings - -from .sync_engine import ( - SyncTransaction, - execute_sync, - plan_sync, - undo_sync, -) -from .tb_models import TBPlan - -logger = logging.getLogger(__name__) - - -class CalendarSubmitter: - """Submit ``TBPlan`` changes to Google Calendar via MCP sync engine. - - Manages the MCP workbench lifecycle and provides submit / undo. - """ - - def __init__( - self, - *, - server_url: str | None = None, - timeout_s: float = 10.0, - ) -> None: - """Initialize the submitter. - - Args: - server_url: MCP calendar server URL. Falls back to config. - timeout_s: HTTP timeout for MCP calls. - """ - self._server_url = server_url or settings.mcp_calendar_server_url - self._timeout_s = timeout_s - self._last_tx: SyncTransaction | None = None - - def _get_workbench(self) -> Any: - """Create an MCP workbench for the calendar server. - - Returns: - An ``McpWorkbench`` instance. - """ - from autogen_ext.tools.mcp import McpWorkbench, StreamableHttpServerParams - - return McpWorkbench( - StreamableHttpServerParams( - url=self._server_url, - timeout=self._timeout_s, - ) - ) - - async def submit_plan( - self, - desired: TBPlan, - *, - remote: TBPlan, - event_id_map: dict[str, str], - remote_event_ids_by_index: list[str] | None = None, - calendar_id: str = "primary", - ) -> SyncTransaction: - """Diff and submit a plan to Google Calendar. - - Args: - desired: The target ``TBPlan`` to sync. - remote: The current remote state (from ``gcal_response_to_tb_plan``). - event_id_map: Maps ``(summary|start_iso)`` β†’ ``gcal_event_id``. - remote_event_ids_by_index: Optional remote IDs aligned with - ``remote.resolve_times()`` order to avoid lossy keying. - calendar_id: Target GCal calendar ID. - - Returns: - A ``SyncTransaction`` with per-op results. - """ - ops = plan_sync( - remote, - desired, - event_id_map, - remote_event_ids_by_index=remote_event_ids_by_index, - calendar_id=calendar_id, - ) - - if not ops: - logger.info("No sync ops needed β€” plans are identical.") - tx = SyncTransaction(status="committed") - self._last_tx = tx - return tx - - logger.info( - "Submitting %d sync ops: %s", - len(ops), - ", ".join(f"{op.op_type.value}({op.gcal_event_id[:12]})" for op in ops), - ) - - wb = self._get_workbench() - tx = await execute_sync(ops, wb, halt_on_error=True) - self._last_tx = tx - - logger.info("Sync transaction status: %s", tx.status) - return tx - - async def undo_last(self) -> SyncTransaction | None: - """Undo the last submitted transaction. - - Returns: - The undo ``SyncTransaction``, or ``None`` if no transaction to undo. - """ - if not self._last_tx or self._last_tx.status not in ("committed", "partial"): - logger.warning("No transaction to undo.") - return None - - wb = self._get_workbench() - undo_tx = await undo_sync(self._last_tx, wb) - self._last_tx = None # Clear after undo - return undo_tx - - async def undo_transaction(self, tx: SyncTransaction) -> SyncTransaction | None: - """Undo a specific submitted transaction. - - Args: - tx: The transaction to undo. - - Returns: - The undo transaction when undo is possible, else ``None``. - """ - if tx.status not in ("committed", "partial"): - logger.warning("Transaction is not undoable (status=%s).", tx.status) - return None - wb = self._get_workbench() - undo_tx = await undo_sync(tx, wb) - if self._last_tx is tx: - self._last_tx = None - return undo_tx - - @property - def last_transaction(self) -> SyncTransaction | None: - """Return the last sync transaction (for inspection / logging).""" - return self._last_tx - - -__all__ = ["CalendarSubmitter"] diff --git a/src/fateforger/agents/timeboxing/sync_engine.py b/src/fateforger/agents/timeboxing/sync_engine.py deleted file mode 100644 index 4369120a..00000000 --- a/src/fateforger/agents/timeboxing/sync_engine.py +++ /dev/null @@ -1,624 +0,0 @@ -"""Deterministic, incremental, reversible calendar sync engine. - -Computes minimal create / update / delete ops by diffing a ``TBPlan`` -(desired schedule) against the remote Google Calendar state fetched via -MCP. Every remote mutation is logged in a ``SyncTransaction`` for -deterministic undo. - -Key design decisions --------------------- -* **DeepDiff** detects meaningful field changes (summary, start, end, - description, colorId) and ignores GCal noise (etag, updated, sequence). -* Agent-owned events are identified by a deterministic ``fftb*`` event-ID - prefix (base32hex). Foreign events are never mutated. -* ``undo_sync`` replays compensating ops in reverse order. -""" - -from __future__ import annotations - -import base64 -import hashlib -import logging -from dataclasses import dataclass, field -from datetime import date as date_type -from datetime import datetime, time, timezone -from enum import Enum -from typing import Any -from zoneinfo import ZoneInfo - -from dateutil import parser as date_parser -from deepdiff import DeepDiff - -from fateforger.adapters.calendar.models import GCalEventsResponse - -from .calendar_reconciliation import reconcile_calendar_ops -from .tb_models import ET_COLOR_MAP, FixedWindow, TBEvent, TBPlan, gcal_color_to_et - -logger = logging.getLogger(__name__) - -FFTB_PREFIX = "fftb" -"""Prefix for agent-owned GCal event IDs.""" - - -# ── Helpers ────────────────────────────────────────────────────────────── - - -def base32hex_id(seed: str, *, prefix: str = FFTB_PREFIX, max_len: int = 64) -> str: - """Deterministic GCal-safe event ID. - - GCal event IDs must contain only lowercase ``a-v`` and ``0-9`` - (base32hex alphabet). - - Args: - seed: Seed string (typically ``date|name|start|index``). - prefix: Prefix for owned events. - max_len: Maximum ID length (GCal allows up to 1024). - - Returns: - A deterministic, GCal-safe event ID string. - """ - digest = hashlib.sha1(seed.encode("utf-8")).digest() - token = base64.b32hexencode(digest).decode("ascii").lower().rstrip("=") - return (prefix + token)[:max_len] - - -def is_owned_event(event_id: str) -> bool: - """Return ``True`` if this event was created by the agent. - - Args: - event_id: Google Calendar event ID. - - Returns: - Whether the event ID starts with the agent prefix. - """ - return event_id.startswith(FFTB_PREFIX) - - -def _calendar_mcp_datetime(value: datetime) -> str: - """Format datetimes to MCP-required ISO8601 without timezone suffix.""" - return value.replace(tzinfo=None, microsecond=0).isoformat() - - -# ── Canonical representation (for DeepDiff) ────────────────────────────── - - -def _canonical(resolved: dict) -> dict[str, str]: - """Reduce a resolved event to the fields we care about for diffing. - - Args: - resolved: A dict from ``TBPlan.resolve_times()``. - - Returns: - Dict with summary, start, end, description, colorId. - """ - return { - "summary": resolved["n"], - "start": resolved["start_time"].isoformat(), - "end": resolved["end_time"].isoformat(), - "description": resolved.get("d", ""), - "colorId": ET_COLOR_MAP.get(resolved["t"], "0"), - } - - -# ── GCal response β†’ TBPlan ────────────────────────────────────────────── - - -def gcal_response_to_tb_plan( - resp: GCalEventsResponse, - *, - plan_date: date_type, - tz_name: str = "Europe/Amsterdam", -) -> tuple[TBPlan, dict[str, str]]: - """Convert a GCal MCP ``list-events`` response into a ``TBPlan``. - - All existing calendar events become ``FixedWindow`` anchors since - they already have concrete start/end times. - - Args: - resp: Parsed GCal events response from MCP. - plan_date: The date we're planning for. - tz_name: IANA timezone name. - - Returns: - Tuple of ``(plan, event_id_map)`` where ``event_id_map`` maps - ``(summary, start_iso)`` β†’ ``gcal_event_id``. - """ - plan, event_id_map, _event_ids_by_index = gcal_response_to_tb_plan_with_identity( - resp, - plan_date=plan_date, - tz_name=tz_name, - ) - return plan, event_id_map - - -def gcal_response_to_tb_plan_with_identity( - resp: GCalEventsResponse, - *, - plan_date: date_type, - tz_name: str = "Europe/Amsterdam", -) -> tuple[TBPlan, dict[str, str], list[str]]: - """Convert list-events response into a TBPlan and ordered remote IDs. - - Returns: - ``(plan, event_id_map, event_ids_by_index)`` where: - - ``event_id_map`` preserves legacy ``summary|start`` lookups. - - ``event_ids_by_index`` aligns with ``plan.resolve_times()`` ordering. - """ - tz = ZoneInfo(tz_name) - event_pairs: list[tuple[TBEvent, str]] = [] - event_id_map: dict[str, str] = {} - - for ge in resp.events: - if not ge.start.date_time or not ge.end.date_time: - continue - if ge.status and ge.status.lower() == "cancelled": - continue - - start_dt = date_parser.isoparse(ge.start.date_time).astimezone(tz) - end_dt = date_parser.isoparse(ge.end.date_time).astimezone(tz) - if start_dt.date() != plan_date: - continue - if end_dt <= start_dt: - continue - - color_id = getattr(ge, "colorId", None) or getattr(ge, "color_id", None) - et = gcal_color_to_et(color_id) - summary = ge.summary or "Busy" - tb_event = TBEvent( - n=summary, - d="", - t=et, - p=FixedWindow(st=start_dt.time(), et=end_dt.time()), - ) - event_pairs.append((tb_event, ge.id)) - key = f"{summary}|{start_dt.time().isoformat()}" - event_id_map.setdefault(key, ge.id) - - event_pairs.sort(key=lambda pair: pair[0].p.st if hasattr(pair[0].p, "st") else time(0, 0)) - events = [pair[0] for pair in event_pairs] - event_ids_by_index = [pair[1] for pair in event_pairs] - plan = TBPlan(events=events, date=plan_date, tz=tz_name) - return plan, event_id_map, event_ids_by_index - - -# ── SyncOp / SyncTransaction ──────────────────────────────────────────── - - -class SyncOpType(str, Enum): - """Type of remote calendar operation.""" - - CREATE = "create" - UPDATE = "update" - DELETE = "delete" - - -@dataclass -class SyncOp: - """A single MCP calendar mutation. - - Attributes: - op_type: create / update / delete. - gcal_event_id: The target GCal event ID. - after_payload: The MCP tool arguments for the forward op. - before_payload: Snapshot before mutation (for undo). - tool_name: MCP tool name (``create-event``, etc.). - """ - - op_type: SyncOpType - gcal_event_id: str - after_payload: dict[str, Any] - before_payload: dict[str, Any] | None = None - diff_paths: tuple[str, ...] = field(default_factory=tuple) - tool_name: str = "" - - def __post_init__(self) -> None: - """Derive ``tool_name`` from ``op_type`` if not set.""" - if not self.tool_name: - self.tool_name = f"{self.op_type.value}-event" - - -@dataclass -class SyncTransaction: - """A batch of sync ops with per-op result tracking. - - Attributes: - ops: List of SyncOps in execution order. - results: Per-op result dicts (populated after execution). - status: Overall status (pending β†’ committed / failed / undone). - timestamp: When the transaction was created. - """ - - ops: list[SyncOp] = field(default_factory=list) - results: list[dict[str, Any]] = field(default_factory=list) - status: str = "pending" - timestamp: str = field( - default_factory=lambda: datetime.now(timezone.utc).isoformat() - ) - - -# ── Plan sync (DeepDiff-based) ─────────────────────────────────────────── - - -def plan_sync( - remote: TBPlan, - desired: TBPlan, - event_id_map: dict[str, str], - *, - remote_event_ids_by_index: list[str] | None = None, - calendar_id: str = "primary", -) -> list[SyncOp]: - """Compute minimal create/update/delete ops with explicit reconciliation.""" - tz = ZoneInfo(desired.tz) - ops: list[SyncOp] = [] - plan = reconcile_calendar_ops( - remote=remote, - desired=desired, - event_id_map=event_id_map, - remote_event_ids_by_index=remote_event_ids_by_index, - ) - - for desired_record in plan.creates: - resolved = desired_record.resolved - start_dt = datetime.combine(desired.date, desired_record.start_time, tzinfo=tz) - end_dt = datetime.combine(desired.date, desired_record.end_time, tzinfo=tz) - seed = ( - f"{desired.date}|{desired_record.summary}|" - f"{desired_record.start_time}|{desired_record.index}" - ) - event_id = base32hex_id(seed) - payload = _build_mcp_payload( - resolved, - event_id=event_id, - start_dt=start_dt, - end_dt=end_dt, - tz_name=desired.tz, - calendar_id=calendar_id, - ) - ops.append( - SyncOp( - op_type=SyncOpType.CREATE, - gcal_event_id=event_id, - after_payload=payload, - ) - ) - - for match in plan.updates: - if not match.remote.event_id: - continue - remote_canonical = _canonical(match.remote.resolved) - desired_canonical = _canonical(match.desired.resolved) - diff = DeepDiff( - remote_canonical, - desired_canonical, - ignore_order=True, - verbose_level=2, - ) - if not diff: - continue - if not match.remote.is_owned: - logger.info( - "Skipping update for foreign event %s (%s).", - match.remote.event_id, - match.match_kind, - ) - continue - - before_start = datetime.combine(remote.date, match.remote.start_time, tzinfo=tz) - before_end = datetime.combine(remote.date, match.remote.end_time, tzinfo=tz) - after_start = datetime.combine(desired.date, match.desired.start_time, tzinfo=tz) - after_end = datetime.combine(desired.date, match.desired.end_time, tzinfo=tz) - before_payload = _build_mcp_payload( - match.remote.resolved, - event_id=match.remote.event_id, - start_dt=before_start, - end_dt=before_end, - tz_name=remote.tz, - calendar_id=calendar_id, - ) - after_payload = _build_mcp_payload( - match.desired.resolved, - event_id=match.remote.event_id, - start_dt=after_start, - end_dt=after_end, - tz_name=desired.tz, - calendar_id=calendar_id, - ) - ops.append( - SyncOp( - op_type=SyncOpType.UPDATE, - gcal_event_id=match.remote.event_id, - after_payload=after_payload, - before_payload=before_payload, - diff_paths=_deepdiff_paths(diff), - ) - ) - - for foreign_match in plan.noops: - remote_canonical = _canonical(foreign_match.remote.resolved) - desired_canonical = _canonical(foreign_match.desired.resolved) - diff = DeepDiff( - remote_canonical, - desired_canonical, - ignore_order=True, - verbose_level=2, - ) - if diff: - logger.info( - "No-op for foreign event match (kind=%s, summary=%s).", - foreign_match.match_kind, - foreign_match.remote.summary, - ) - - for remote_record in plan.deletes: - if not remote_record.event_id: - continue - before_start = datetime.combine(remote.date, remote_record.start_time, tzinfo=tz) - before_end = datetime.combine(remote.date, remote_record.end_time, tzinfo=tz) - before_payload = _build_mcp_payload( - remote_record.resolved, - event_id=remote_record.event_id, - start_dt=before_start, - end_dt=before_end, - tz_name=remote.tz, - calendar_id=calendar_id, - ) - ops.append( - SyncOp( - op_type=SyncOpType.DELETE, - gcal_event_id=remote_record.event_id, - after_payload={"calendarId": calendar_id, "eventId": remote_record.event_id}, - before_payload=before_payload, - ) - ) - - for skipped in plan.skips: - logger.debug( - "Sync skip: %s (desired_index=%s remote_index=%s)", - skipped.reason, - skipped.desired_index, - skipped.remote_index, - ) - - order = {SyncOpType.CREATE: 0, SyncOpType.UPDATE: 1, SyncOpType.DELETE: 2} - ops.sort(key=lambda op: order.get(op.op_type, 99)) - return ops - - -# ── Execute / Undo ─────────────────────────────────────────────────────── - - -async def execute_sync( - ops: list[SyncOp], - mcp_workbench: Any, - *, - halt_on_error: bool = False, -) -> SyncTransaction: - """Execute sync ops against the MCP calendar server. - - Args: - ops: Ordered list of ``SyncOp`` to execute. - mcp_workbench: An ``McpWorkbench`` instance for MCP tool calls. - - Returns: - A ``SyncTransaction`` with per-op results and overall status. - """ - tx = SyncTransaction(ops=ops) - all_ok = True - - for op in ops: - try: - result = await mcp_workbench.call_tool( - op.tool_name, - arguments=op.after_payload, - ) - is_error = getattr(result, "is_error", False) - content = _extract_result_content(result) - tx.results.append( - { - "tool": op.tool_name, - "event_id": op.gcal_event_id, - "op_type": op.op_type.value, - "diff_paths": list(op.diff_paths), - "ok": not is_error, - "content": content, - } - ) - if is_error: - all_ok = False - logger.warning( - "Sync op failed: %s %s β€” %s", - op.tool_name, - op.gcal_event_id, - content, - ) - if halt_on_error: - break - except Exception as exc: - all_ok = False - tx.results.append( - { - "tool": op.tool_name, - "event_id": op.gcal_event_id, - "op_type": op.op_type.value, - "diff_paths": list(op.diff_paths), - "ok": False, - "error": str(exc), - } - ) - logger.exception("Sync op exception: %s %s", op.tool_name, op.gcal_event_id) - if halt_on_error: - break - - if all_ok: - tx.status = "committed" - elif halt_on_error: - tx.status = "partial_halted" - else: - tx.status = "partial" - return tx - - -async def undo_sync( - tx: SyncTransaction, - mcp_workbench: Any, -) -> SyncTransaction: - """Undo a committed sync transaction via compensating ops. - - * Created events β†’ delete - * Updated events β†’ update with ``before_payload`` - * Deleted events β†’ create with ``before_payload`` - - Args: - tx: The transaction to undo. - mcp_workbench: An ``McpWorkbench`` instance. - - Returns: - A new ``SyncTransaction`` representing the undo. - """ - if len(tx.results) != len(tx.ops): - raise ValueError( - "Cannot undo sync transaction without complete per-op execution results." - ) - - successful_forward_ops = [ - op for index, op in enumerate(tx.ops) if bool(tx.results[index].get("ok")) - ] - undo_ops: list[SyncOp] = [] - - for op in reversed(successful_forward_ops): - if op.op_type == SyncOpType.CREATE: - # Undo create β†’ delete - undo_ops.append( - SyncOp( - op_type=SyncOpType.DELETE, - gcal_event_id=op.gcal_event_id, - after_payload={ - "calendarId": op.after_payload.get("calendarId", "primary"), - "eventId": op.gcal_event_id, - }, - ) - ) - elif op.op_type == SyncOpType.UPDATE and op.before_payload: - # Undo update β†’ restore previous state - undo_ops.append( - SyncOp( - op_type=SyncOpType.UPDATE, - gcal_event_id=op.gcal_event_id, - after_payload=op.before_payload, - ) - ) - elif op.op_type == SyncOpType.DELETE and op.before_payload: - # Undo delete β†’ recreate - undo_ops.append( - SyncOp( - op_type=SyncOpType.CREATE, - gcal_event_id=op.gcal_event_id, - after_payload=op.before_payload, - ) - ) - - if not undo_ops: - return SyncTransaction(status="undone") - - undo_tx = await execute_sync(undo_ops, mcp_workbench) - undo_tx.status = "undone" if undo_tx.status == "committed" else "undo_partial" - return undo_tx - - -# ── Internal helpers ───────────────────────────────────────────────────── - - -def _build_mcp_payload( - resolved: dict, - *, - event_id: str, - start_dt: datetime, - end_dt: datetime, - tz_name: str, - calendar_id: str, -) -> dict[str, Any]: - """Build the MCP tool argument dict for a calendar event. - - Args: - resolved: A dict from ``TBPlan.resolve_times()``. - event_id: GCal event ID. - start_dt: Timezone-aware start datetime. - end_dt: Timezone-aware end datetime. - tz_name: IANA timezone name. - calendar_id: Target calendar ID. - - Returns: - Dict suitable for ``create-event`` or ``update-event`` MCP tools. - """ - return { - "calendarId": calendar_id, - "eventId": event_id, - "summary": resolved["n"], - "description": resolved.get("d", ""), - "start": _calendar_mcp_datetime(start_dt), - "end": _calendar_mcp_datetime(end_dt), - "timeZone": tz_name, - "colorId": ET_COLOR_MAP.get(resolved["t"], "0"), - } - - -def _extract_result_content(result: Any) -> str: - """Extract content text from an MCP tool result. - - Args: - result: Raw MCP tool result object. - - Returns: - Content string. - """ - # result.result is a list of content objects - inner = getattr(result, "result", None) - if isinstance(inner, list): - parts = [] - for item in inner: - text = getattr(item, "text", None) or getattr(item, "content", None) - if text: - parts.append(str(text)) - if parts: - return "\n".join(parts) - - text = getattr(result, "text", None) or getattr(result, "content", None) - if text: - return str(text) - - return str(result) - - -def _deepdiff_paths(diff: Any) -> tuple[str, ...]: - """Extract stable changed-paths from a DeepDiff payload.""" - if hasattr(diff, "to_dict"): - payload = diff.to_dict() - elif isinstance(diff, dict): - payload = diff - else: - return () - paths: set[str] = set() - for value in payload.values(): - if isinstance(value, dict): - for path in value.keys(): - paths.add(str(path)) - continue - if isinstance(value, (list, set, tuple)): - for item in value: - paths.add(str(item)) - return tuple(sorted(paths)) - - -__all__ = [ - "FFTB_PREFIX", - "SyncOp", - "SyncOpType", - "SyncTransaction", - "base32hex_id", - "execute_sync", - "gcal_response_to_tb_plan", - "gcal_response_to_tb_plan_with_identity", - "is_owned_event", - "plan_sync", - "undo_sync", -] diff --git a/src/fateforger/agents/timeboxing/task_marshalling_capability.py b/src/fateforger/agents/timeboxing/task_marshalling_capability.py deleted file mode 100644 index 2240eeae..00000000 --- a/src/fateforger/agents/timeboxing/task_marshalling_capability.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Composable task-marshalling capability for timeboxing sessions.""" - -from __future__ import annotations - -import asyncio -import logging -from typing import Any, Awaitable, Callable, Protocol - -from autogen_agentchat.messages import TextMessage -from autogen_core import AgentId, CancellationToken -from pydantic import BaseModel - -from fateforger.agents.tasks.messages import ( - PendingTaskSnapshot, - PendingTaskSnapshotRequest, -) -from fateforger.debug.diag import with_timeout - -from .contracts import TaskCandidate -from .pydantic_parsing import parse_model_list, parse_model_optional - -SendMessageFn = Callable[..., Awaitable[Any]] -AppendBackgroundFn = Callable[[Any, str], None] -logger = logging.getLogger(__name__) - - -class SessionTaskState(Protocol): - """Minimal session state contract required by task-marshalling capability.""" - - user_id: str - channel_id: str - thread_ts: str - session_key: str | None - input_facts: dict[str, Any] - prefetched_pending_tasks: list[TaskCandidate] - pending_tasks_prefetch: bool - - -class TaskAssistRequest(BaseModel): - """Typed request envelope for assist-turn task delegation.""" - - note: str | None = None - user_message: str - - def to_text_message(self) -> str: - """Render one deterministic text payload for tasks_agent delegation.""" - note = (self.note or "").strip() - message = self.user_message.strip() - if not note: - return message - return f"{message}\n\nAssist context: {note}" - - -class TaskMarshallingCapability: - """Encapsulates pending-task prefetch and assist-turn delegation.""" - - def __init__( - self, - *, - send_message: SendMessageFn, - timeout_s: float, - source_resolver: Callable[[], str], - ) -> None: - self._send_message = send_message - self._timeout_s = timeout_s - self._source_resolver = source_resolver - - @staticmethod - def tasks_agent_recipient(session: SessionTaskState) -> AgentId: - """Build the tasks-agent recipient scoped to this user thread.""" - key = session.session_key or f"{session.channel_id}:{session.thread_ts}" - return AgentId("tasks_agent", key=key) - - @staticmethod - def merge_prefetched_tasks( - *, - input_facts: dict[str, Any], - prefetched: list[TaskCandidate], - ) -> dict[str, Any]: - """Inject prefetched tasks only when no explicit task list exists.""" - merged = dict(input_facts or {}) - match bool(parse_model_list(TaskCandidate, merged.get("tasks"))), bool(prefetched): - case (True, _) | (_, False): - return merged - case (False, True): - merged["tasks"] = [task.model_dump(mode="json") for task in prefetched] - return merged - return merged - - async def request_pending_tasks( - self, - *, - session: SessionTaskState, - query: str | None = None, - limit: int = 12, - ) -> list[TaskCandidate]: - """Fetch typed pending tasks from tasks_agent.""" - request = PendingTaskSnapshotRequest( - user_id=session.user_id, - limit=limit, - query=query, - ) - try: - raw = await with_timeout( - "timeboxing:tasks:pending_snapshot", - self._send_message( - request, - recipient=self.tasks_agent_recipient(session), - cancellation_token=CancellationToken(), - ), - timeout_s=self._timeout_s, - dump_on_timeout=False, - dump_threads_on_timeout=False, - ) - except Exception as exc: - logger.warning( - "Task marshalling pending snapshot failed", - extra={ - "event": "task_marshalling_pending_snapshot_failed", - "session_key": session.session_key or "", - "channel_id": session.channel_id, - "thread_ts": session.thread_ts, - "error_type": type(exc).__name__, - "error": str(exc), - }, - ) - return [] - snapshot = ( - raw if isinstance(raw, PendingTaskSnapshot) else parse_model_optional(PendingTaskSnapshot, raw) - ) - match snapshot: - case None: - logger.warning( - "Task marshalling pending snapshot payload was invalid", - extra={ - "event": "task_marshalling_pending_snapshot_invalid_payload", - "session_key": session.session_key or "", - "channel_id": session.channel_id, - "thread_ts": session.thread_ts, - "payload_type": type(raw).__name__, - }, - ) - return [] - case _: - return [ - TaskCandidate(title=item.title) - for item in snapshot.items - if (item.title or "").strip() - ] - - @staticmethod - def apply_prefetched_tasks( - *, - session: SessionTaskState, - tasks: list[TaskCandidate], - ) -> None: - """Write prefetched tasks into session cache and capture-input facts.""" - session.prefetched_pending_tasks = tasks - session.input_facts = TaskMarshallingCapability.merge_prefetched_tasks( - input_facts=dict(session.input_facts or {}), - prefetched=tasks, - ) - - def queue_prefetch( - self, - *, - session: SessionTaskState, - reason: str, - append_background_update: AppendBackgroundFn, - ) -> None: - """Start non-blocking prefetch from task-marshalling.""" - if session.pending_tasks_prefetch: - return - session.pending_tasks_prefetch = True - - async def _background() -> None: - try: - tasks = await self.request_pending_tasks(session=session) - match len(tasks): - case 0: - return - case n: - self.apply_prefetched_tasks(session=session, tasks=tasks) - append_background_update( - session, - f"Loaded {n} pending task candidate(s) from task-marshalling ({reason}).", - ) - finally: - session.pending_tasks_prefetch = False - - asyncio.create_task(_background()) - - async def assist_response( - self, - *, - session: SessionTaskState, - user_message: str, - note: str | None, - ) -> str | None: - """Handle assist turn via typed delegation to tasks_agent.""" - request = TaskAssistRequest(note=note, user_message=user_message) - if not request.user_message.strip(): - return None - try: - reply = await with_timeout( - "timeboxing:assist:tasks_agent", - self._send_message( - TextMessage( - content=request.to_text_message(), - source=self._source_resolver(), - ), - recipient=self.tasks_agent_recipient(session), - cancellation_token=CancellationToken(), - ), - timeout_s=self._timeout_s, - dump_on_timeout=False, - dump_threads_on_timeout=False, - ) - except Exception as exc: - logger.warning( - "Task marshalling assist delegation failed", - extra={ - "event": "task_marshalling_assist_failed", - "session_key": session.session_key or "", - "channel_id": session.channel_id, - "thread_ts": session.thread_ts, - "error_type": type(exc).__name__, - "error": str(exc), - }, - ) - return None - return reply.content if isinstance(reply, TextMessage) else str( - getattr(reply, "content", None) or reply or "" - ) - - -__all__ = [ - "SessionTaskState", - "TaskAssistRequest", - "TaskMarshallingCapability", -] diff --git a/src/fateforger/agents/timeboxing/tb_models.py b/src/fateforger/agents/timeboxing/tb_models.py deleted file mode 100644 index 51f8871b..00000000 --- a/src/fateforger/agents/timeboxing/tb_models.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Lightweight timebox models for token-efficient LLM generation. - -These are the **sole LLM-facing models** for timebox planning. Heavy -``CalendarEvent`` (SQLModel) stays for DB persistence / Slack display; -never pass it to an LLM. - -Extracted from ``notebooks/making_timebox_session_stage_4_work.ipynb`` cell 33. -""" - -from __future__ import annotations - -from datetime import date as date_type -from datetime import datetime, time, timedelta -from enum import Enum -from typing import Annotated, Literal, Union - -from isodate import parse_duration as _parse_dur -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator - -# ── EventType (compact codes, no SQLAlchemy) ────────────────────────────── - - -class ET(str, Enum): - """Event type β€” compact codes for LLM generation.""" - - M = "M" # meeting - C = "C" # commute - DW = "DW" # deep work - SW = "SW" # shallow work - PR = "PR" # plan & review - H = "H" # habit / routine - R = "R" # regeneration (meals, sleep, rest) - BU = "BU" # buffer - BG = "BG" # background (must have fixed timing) - - -# Map ET codes β†’ Google Calendar colorId strings. -ET_COLOR_MAP: dict[str, str] = { - "M": "6", - "C": "4", - "DW": "9", - "SW": "8", - "PR": "10", - "H": "7", - "R": "2", - "BU": "5", - "BG": "1", -} - - -def gcal_color_to_et(color_id: str | None) -> ET: - """Best-effort reverse mapping: GCal colorId β†’ ET code.""" - _reverse = {v: k for k, v in ET_COLOR_MAP.items()} - if color_id and color_id in _reverse: - return ET(_reverse[color_id]) - return ET.M # default: treat unknown calendar events as meetings - - -# ── Time anchoring (discriminated union on field ``a``) ─────────────────── - - -class AfterPrev(BaseModel): - """Starts immediately after the previous event ends. Default.""" - - model_config = ConfigDict(extra="forbid") - a: Literal["ap"] = "ap" - dur: timedelta = Field(..., description="Duration (ISO 8601, e.g. PT30M)") - - _parse = field_validator("dur", mode="before")( - lambda cls, v: _parse_dur(v) if isinstance(v, str) else v - ) - - -class BeforeNext(BaseModel): - """Ends immediately when the next event starts.""" - - model_config = ConfigDict(extra="forbid") - a: Literal["bn"] = "bn" - dur: timedelta = Field(..., description="Duration (ISO 8601)") - - _parse = field_validator("dur", mode="before")( - lambda cls, v: _parse_dur(v) if isinstance(v, str) else v - ) - - -class FixedStart(BaseModel): - """Pinned to a specific start time.""" - - model_config = ConfigDict(extra="forbid") - a: Literal["fs"] = "fs" - st: time = Field(..., description="Start time (HH:MM)") - dur: timedelta = Field(..., description="Duration (ISO 8601)") - - _parse_t = field_validator("st", mode="before")( - lambda cls, v: time.fromisoformat(v) if isinstance(v, str) else v - ) - _parse_d = field_validator("dur", mode="before")( - lambda cls, v: _parse_dur(v) if isinstance(v, str) else v - ) - - -class FixedWindow(BaseModel): - """Pinned start and end β€” for meetings, background events, etc.""" - - model_config = ConfigDict(extra="forbid") - a: Literal["fw"] = "fw" - st: time = Field(..., description="Start time (HH:MM)") - et: time = Field(..., description="End time (HH:MM)") - - _parse_st = field_validator("st", mode="before")( - lambda cls, v: time.fromisoformat(v) if isinstance(v, str) else v - ) - _parse_et = field_validator("et", mode="before")( - lambda cls, v: time.fromisoformat(v) if isinstance(v, str) else v - ) - - -Timing = Annotated[ - Union[AfterPrev, BeforeNext, FixedStart, FixedWindow], - Field(discriminator="a"), -] - - -# ── TBEvent (the generation-time event model) ──────────────────────────── - - -class TBEvent(BaseModel): - """A single timeboxed event β€” minimal fields for LLM generation. - - ~40 tokens per event vs ~180 for production ``CalendarEvent``. - """ - - model_config = ConfigDict(extra="forbid") - - n: str = Field(..., description="Event name / summary") - d: str = Field("", description="Short description") - t: ET = Field(..., description="Event type code") - p: Timing = Field(..., description="Time placement") - - @model_validator(mode="after") - def bg_needs_fixed(self) -> "TBEvent": - """Background events must have a fixed window or fixed start.""" - if self.t == ET.BG and self.p.a not in ("fs", "fw"): - raise ValueError( - "Background events (BG) require fixed_start or fixed_window timing" - ) - return self - - -# ── TBPlan (the generation-time timebox) ────────────────────────────────── - - -class TBPlan(BaseModel): - """A day's timebox plan β€” lightweight container for LLM generation.""" - - model_config = ConfigDict(extra="forbid") - - events: list[TBEvent] = Field(default_factory=list) - date: date_type = Field(default_factory=date_type.today) - tz: str = Field(default="Europe/Amsterdam", description="IANA timezone") - - @model_validator(mode="after") - def chain_must_be_anchored(self) -> "TBPlan": - """At least one non-BG event must have a fixed time to anchor the chain.""" - chain = [e for e in self.events if e.t != ET.BG] - if chain and not any(e.p.a in ("fs", "fw") for e in chain): - raise ValueError( - "Event chain needs at least one fixed_start or fixed_window anchor" - ) - return self - - def resolve_times(self, *, validate_non_overlap: bool = True) -> list[dict]: - """Deterministically compute concrete start/end for every event. - - Returns: - List of dicts with keys: ``n``, ``d``, ``t``, ``start_time``, - ``end_time``, ``duration``, ``index``. - """ - planning_date = self.date - resolved: list[dict] = [] - - # ── Forward pass: after_previous, fixed_start, fixed_window ── - last_end_dt: datetime | None = None - for i, ev in enumerate(self.events): - r: dict = {"n": ev.n, "d": ev.d, "t": ev.t.value, "index": i} - p = ev.p - - if p.a == "ap": # after_previous - if last_end_dt is None: - raise ValueError( - f"Event '{ev.n}' (after_previous) has no preceding event" - ) - start_dt = last_end_dt - end_dt = start_dt + p.dur - r.update( - start_time=start_dt.time(), - end_time=end_dt.time(), - duration=p.dur, - ) - - elif p.a == "fs": # fixed_start - start_dt = datetime.combine(planning_date, p.st) - end_dt = start_dt + p.dur - r.update( - start_time=p.st, - end_time=end_dt.time(), - duration=p.dur, - ) - - elif p.a == "fw": # fixed_window - start_dt = datetime.combine(planning_date, p.st) - end_dt = datetime.combine(planning_date, p.et) - r.update( - start_time=p.st, - end_time=p.et, - duration=end_dt - start_dt, - ) - - elif p.a == "bn": # before_next β€” resolved in backward pass - r.update(duration=p.dur, _pending="bn") - resolved.append(r) - continue # don't update last_end_dt yet - - last_end_dt = datetime.combine(planning_date, r["end_time"]) - resolved.append(r) - - # ── Backward pass: resolve before_next ── - next_start_dt: datetime | None = None - for r in reversed(resolved): - if r.get("_pending") == "bn": - if next_start_dt is None: - raise ValueError( - f"Event '{r['n']}' (before_next) has no following event" - ) - end_dt = next_start_dt - start_dt = end_dt - r["duration"] - r.update(start_time=start_dt.time(), end_time=end_dt.time()) - del r["_pending"] - if "start_time" in r: - next_start_dt = datetime.combine(planning_date, r["start_time"]) - - # ── Overlap check (non-BG only) ── - # Desired/generated plans should remain strict, but remote calendar - # snapshots can legitimately contain overlaps from prior edits. - if validate_non_overlap: - chain = [r for r in resolved if r["t"] != "BG"] - for a, b in zip(chain, chain[1:]): - a_end = datetime.combine(planning_date, a["end_time"]) - b_start = datetime.combine(planning_date, b["start_time"]) - if a_end > b_start: - raise ValueError( - f"Overlap: '{a['n']}' ends {a['end_time']} " - f"but '{b['n']}' starts {b['start_time']}" - ) - - return resolved - - -__all__ = [ - "ET", - "ET_COLOR_MAP", - "AfterPrev", - "BeforeNext", - "FixedStart", - "FixedWindow", - "TBEvent", - "TBPlan", - "Timing", - "gcal_color_to_et", -] diff --git a/src/fateforger/agents/timeboxing/tb_ops.py b/src/fateforger/agents/timeboxing/tb_ops.py deleted file mode 100644 index b03006de..00000000 --- a/src/fateforger/agents/timeboxing/tb_ops.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Domain-specific operations for timebox patching. - -Typed domain ops replace generic JSON Patch. The LLM picks the op type -and gets a schema-enforced structure β€” no ``value: Any`` or path strings. - -Extracted from ``notebooks/making_timebox_session_stage_4_work.ipynb`` cell 34. -""" - -from __future__ import annotations - -from typing import Annotated, Literal, Union - -from pydantic import BaseModel, ConfigDict, Field - -from .tb_models import ET, TBEvent, TBPlan, Timing - -# ── Operations (discriminated union on ``op``) ──────────────────────────── - - -class AddEvents(BaseModel): - """Add one or more events. ``after`` = insert position (``None`` β†’ append).""" - - model_config = ConfigDict(extra="forbid") - op: Literal["ae"] = "ae" - events: list[TBEvent] = Field(..., min_length=1) - after: int | None = Field(None, description="Insert after this index (None=append)") - - -class RemoveEvent(BaseModel): - """Remove an event by index.""" - - model_config = ConfigDict(extra="forbid") - op: Literal["re"] = "re" - i: int = Field(..., description="Index of event to remove") - - -class UpdateEvent(BaseModel): - """Update specific fields on an existing event. Only set fields are changed.""" - - model_config = ConfigDict(extra="forbid") - op: Literal["ue"] = "ue" - i: int = Field(..., description="Index of event to update") - n: str | None = Field(None, description="New name") - d: str | None = Field(None, description="New description") - t: ET | None = Field(None, description="New event type") - p: Timing | None = Field(None, description="New time placement") - - -class MoveEvent(BaseModel): - """Move an event to a different position in the ordered list.""" - - model_config = ConfigDict(extra="forbid") - op: Literal["me"] = "me" - fr: int = Field(..., description="From index") - to: int = Field(..., description="To index") - - -class ReplaceAll(BaseModel): - """Replace the entire event list (initial generation or full rebuild).""" - - model_config = ConfigDict(extra="forbid") - op: Literal["ra"] = "ra" - events: list[TBEvent] = Field(..., min_length=1) - - -TBOp = Annotated[ - Union[AddEvents, RemoveEvent, UpdateEvent, MoveEvent, ReplaceAll], - Field(discriminator="op"), -] - - -class TBPatch(BaseModel): - """A batch of typed operations to apply to a TBPlan.""" - - model_config = ConfigDict(extra="forbid") - ops: list[TBOp] = Field(..., min_length=1) - - -# ── Patch applicator ───────────────────────────────────────────────────── - - -def apply_tb_ops(plan: TBPlan, patch: TBPatch) -> TBPlan: - """Apply domain operations sequentially, return a new validated ``TBPlan``. - - Args: - plan: The current plan. - patch: Batch of typed operations to apply. - - Returns: - A new ``TBPlan`` with the operations applied. - - Raises: - IndexError: If an operation references an out-of-range event index. - """ - events = list(plan.events) # mutable copy - - for op in patch.ops: - match op.op: - case "ae": # add_events - if op.after is not None: - for offset, ev in enumerate(op.events): - events.insert(op.after + 1 + offset, ev) - else: - events.extend(op.events) - - case "re": # remove_event - if op.i < 0 or op.i >= len(events): - raise IndexError( - f"remove: index {op.i} out of range (0..{len(events) - 1})" - ) - events.pop(op.i) - - case "ue": # update_event - if op.i < 0 or op.i >= len(events): - raise IndexError( - f"update: index {op.i} out of range (0..{len(events) - 1})" - ) - current = events[op.i] - merged = current.model_dump() - updates = { - k: v - for k, v in [("n", op.n), ("d", op.d), ("t", op.t), ("p", op.p)] - if v is not None - } - # Serialize Pydantic models / enums so model_validate re-validates - if "p" in updates and isinstance(updates["p"], BaseModel): - updates["p"] = updates["p"].model_dump() - if "t" in updates and isinstance(updates["t"], ET): - updates["t"] = updates["t"].value - merged.update(updates) - events[op.i] = TBEvent.model_validate(merged) - - case "me": # move_event - if op.fr < 0 or op.fr >= len(events): - raise IndexError(f"move: from_index {op.fr} out of range") - ev = events.pop(op.fr) - to = min(op.to, len(events)) - events.insert(to, ev) - - case "ra": # replace_all - events = list(op.events) - - return TBPlan(events=events, date=plan.date, tz=plan.tz) - - -__all__ = [ - "AddEvents", - "MoveEvent", - "RemoveEvent", - "ReplaceAll", - "TBOp", - "TBPatch", - "UpdateEvent", - "apply_tb_ops", -] diff --git a/src/fateforger/agents/timeboxing/timebox.py b/src/fateforger/agents/timeboxing/timebox.py deleted file mode 100644 index 3d42edf7..00000000 --- a/src/fateforger/agents/timeboxing/timebox.py +++ /dev/null @@ -1,341 +0,0 @@ -"""Timebox schema and validation for patching workflows. - -Also provides conversion functions between the heavy ``Timebox`` -(``CalendarEvent``-based, for DB persistence / Slack display) and the -lightweight ``TBPlan`` (for LLM generation / sync engine). -""" - -from __future__ import annotations - -from datetime import date as date_type -from datetime import datetime, time, timedelta -import logging -from typing import List, Optional - -from isodate import parse_duration -from pydantic import BaseModel, Field, model_validator - -from fateforger.agents.schedular.models.calendar import CalendarEvent, EventType - -from .tb_models import ( - ET, - ET_COLOR_MAP, - AfterPrev, - BeforeNext, - FixedStart, - FixedWindow, - TBEvent, - TBPlan, - gcal_color_to_et, -) - -logger = logging.getLogger(__name__) - - -class Timebox(BaseModel): - events: List[CalendarEvent] = Field(default_factory=list) - date: date_type = Field( - default_factory=date_type.today, - description="Date the timebox applies to (local date)", - ) - timezone: str = Field(default="UTC") - - @model_validator(mode="after") - def schedule_and_validate(self) -> "Timebox": - """Fill missing start/end/duration fields and reject overlaps.""" - planning_date = self.date or date_type.today() - events = list(self.events or []) - - def _event_label(event: CalendarEvent) -> str: - """Return a stable human-readable event identifier for errors.""" - uid = getattr(event, "uid", None) - if isinstance(uid, str) and uid.strip(): - return uid.strip() - event_id = getattr(event, "eventId", None) - if isinstance(event_id, str) and event_id.strip(): - return event_id.strip() - summary = getattr(event, "summary", None) - if isinstance(summary, str) and summary.strip(): - return summary.strip() - return "event" - - def _ensure_time(value: time | str | None) -> time | None: - """Coerce HH:MM(/:SS) strings into `datetime.time`.""" - if value is None: - return None - if isinstance(value, time): - return value - return time.fromisoformat(value) - - def _ensure_duration(value: timedelta | str | None) -> timedelta | None: - """Coerce ISO8601 duration strings into `datetime.timedelta`.""" - if value is None: - return None - if isinstance(value, timedelta): - return value - return parse_duration(value) - - def _time_from_datetime_like(value: object | None) -> time | None: - """Extract time-of-day from datetime/date/string anchors.""" - if value is None: - return None - if isinstance(value, datetime): - return value.time() - if isinstance(value, date_type): - return time.min - if isinstance(value, str): - text = value.strip() - if not text: - return None - try: - if "T" in text: - return datetime.fromisoformat(text).time() - date_type.fromisoformat(text) - return time.min - except Exception: - return None - return None - - last_dt: datetime | None = None - for ev in events: - # Accept scheduler-style datetime anchors when time-only fields are absent. - if ev.start_time is None and ev.start is not None: - ev.start_time = _time_from_datetime_like(ev.start) - if ev.end_time is None and ev.end is not None: - ev.end_time = _time_from_datetime_like(ev.end) - ev.start_time = _ensure_time(ev.start_time) - ev.end_time = _ensure_time(ev.end_time) - ev.duration = _ensure_duration(ev.duration) - if ev.start_time and ev.duration and ev.end_time is None: - ev.end_time = ( - datetime.combine(planning_date, ev.start_time) + ev.duration - ).time() - elif ev.end_time and ev.duration and ev.start_time is None: - ev.start_time = ( - datetime.combine(planning_date, ev.end_time) - ev.duration - ).time() - elif ev.start_time and ev.end_time and ev.duration is None: - ev.duration = datetime.combine( - planning_date, ev.end_time - ) - datetime.combine(planning_date, ev.start_time) - - if ev.start_time is None and ev.end_time is None and ev.anchor_prev: - if last_dt is None: - raise ValueError(f"{_event_label(ev)}: needs start or duration") - if ev.duration is None: - raise ValueError(f"{_event_label(ev)}: needs duration") - ev.start_time = last_dt.time() - ev.end_time = (last_dt + ev.duration).time() - - if ev.end_time: - last_dt = datetime.combine(planning_date, ev.end_time) - - next_dt: datetime | None = None - for ev in reversed(events): - if (not ev.anchor_prev) and ev.start_time is None and ev.end_time is None: - if next_dt is None: - raise ValueError(f"{_event_label(ev)}: needs end or duration") - if ev.duration is None: - raise ValueError(f"{_event_label(ev)}: needs duration") - ev.end_time = next_dt.time() - ev.start_time = (next_dt - ev.duration).time() - if ev.start_time: - next_dt = datetime.combine(planning_date, ev.start_time) - - for a, b in zip(events, events[1:]): - if not a.end_time or not b.start_time: - raise ValueError("Events must have start/end after scheduling") - dt_a_end = datetime.combine(planning_date, a.end_time) - dt_b_start = datetime.combine(planning_date, b.start_time) - if dt_a_end > dt_b_start: - a_label = getattr(a, "summary", "event") - b_label = getattr(b, "summary", "event") - raise ValueError(f"Overlap: {a_label} β†’ {b_label}") - - if events: - last_event = events[-1] - if last_event.start_time: - dt_last_start = datetime.combine(planning_date, last_event.start_time) - if dt_last_start.date() != planning_date: - raise ValueError( - f"{_event_label(last_event)}: start {dt_last_start} is not on {planning_date}" - ) - - self.events = events - return self - - -# ── Conversion: TBPlan ↔ Timebox ───────────────────────────────────────── - -# Map ET compact codes to EventType enum members. -_ET_TO_EVENT_TYPE: dict[str, EventType] = { - "M": EventType.MEETING, - "C": EventType.COMMUTE, - "DW": EventType.DEEP_WORK, - "SW": EventType.SHALLOW_WORK, - "PR": EventType.PLAN_REVIEW, - "H": EventType.HABIT, - "R": EventType.REGENERATION, - "BU": EventType.BUFFER, - "BG": EventType.BACKGROUND, -} - -_EVENT_TYPE_TO_ET: dict[str, str] = {v.value: k for k, v in _ET_TO_EVENT_TYPE.items()} - - -def tb_plan_to_timebox(plan: TBPlan) -> Timebox: - """Convert a lightweight ``TBPlan`` to a heavy ``Timebox``. - - Resolves concrete times and creates ``CalendarEvent`` instances - for persistence and Slack display. - - Args: - plan: The lightweight plan. - - Returns: - A ``Timebox`` with fully resolved ``CalendarEvent`` list. - """ - resolved = plan.resolve_times() - events: list[CalendarEvent] = [] - - for r in resolved: - et_code = r["t"] - event_type = _ET_TO_EVENT_TYPE.get(et_code, EventType.MEETING) - - events.append( - CalendarEvent( - summary=r["n"], - description=r.get("d", ""), - event_type=event_type, - start_time=r["start_time"], - end_time=r["end_time"], - timeZone=plan.tz, - ) - ) - - return Timebox(events=events, date=plan.date, timezone=plan.tz) - - -def timebox_to_tb_plan(timebox: Timebox, *, validate: bool = True) -> TBPlan: - """Convert a heavy ``Timebox`` to a lightweight ``TBPlan``. - - Each ``CalendarEvent`` becomes a ``TBEvent`` with ``FixedWindow`` - timing (since concrete times are already resolved). - - Args: - timebox: The heavy timebox. - validate: When ``True`` (default), enforce full ``TBPlan`` validation. - When ``False``, return a model-constructed plan that may still need - repair by the Stage 4 patch loop. - - Returns: - A ``TBPlan`` with ``FixedWindow`` events. - """ - tb_events: list[TBEvent] = [] - - def _time_from_datetime_like(value: object | None) -> time | None: - """Extract time-of-day from datetime/date/string anchors.""" - if value is None: - return None - if isinstance(value, time): - return value - if isinstance(value, datetime): - return value.time().replace(tzinfo=None) - if isinstance(value, date_type): - return time.min - if isinstance(value, str): - text = value.strip() - if not text: - return None - try: - if "T" in text: - return datetime.fromisoformat(text).time().replace(tzinfo=None) - date_type.fromisoformat(text) - return time.min - except Exception: - return None - return None - - for ev in timebox.events: - summary_value = getattr(ev, "summary", None) - if isinstance(summary_value, str): - name = summary_value.strip() - else: - name = "" - if not name: - event_id = getattr(ev, "eventId", None) - fallback = ( - event_id.strip() - if isinstance(event_id, str) and event_id.strip() - else "Busy" - ) - logger.warning( - "timebox_to_tb_plan missing summary; using fallback name='%s' event_id='%s'", - fallback, - event_id, - ) - name = fallback - - start_time = ev.start_time or _time_from_datetime_like(getattr(ev, "start", None)) - end_time = ev.end_time or _time_from_datetime_like(getattr(ev, "end", None)) - duration = ev.duration - start_dt = getattr(ev, "start", None) - end_dt = getattr(ev, "end", None) - if ( - duration is None - and isinstance(start_dt, datetime) - and isinstance(end_dt, datetime) - and end_dt > start_dt - ): - duration = end_dt - start_dt - - # Map EventType β†’ ET code - event_type = ev.event_type - if not isinstance(event_type, EventType): - try: - event_type = EventType(event_type) - except Exception: - event_type = EventType.MEETING - et_code_str = _EVENT_TYPE_TO_ET.get(event_type.value, "M") - et = ET(et_code_str) - - # Use concrete times if available - if start_time and end_time: - if duration and end_time <= start_time: - # Cross-midnight windows cannot be represented as same-day FW; - # represent as fixed-start + duration. - timing = FixedStart(st=start_time, dur=duration) - else: - timing = FixedWindow(st=start_time, et=end_time) - elif start_time and duration: - timing = FixedStart(st=start_time, dur=duration) - elif end_time and duration and getattr(ev, "anchor_prev", True) is False: - timing = BeforeNext(dur=duration) - elif duration: - timing = AfterPrev(dur=duration) - else: - raise ValueError( - "timebox_to_tb_plan: event cannot be mapped to TB timing " - f"(summary={name!r})" - ) - - tb_events.append( - TBEvent( - n=name, - d=ev.description or "", - t=et, - p=timing, - ) - ) - - payload = { - "events": tb_events, - "date": timebox.date, - "tz": timebox.timezone, - } - if validate: - return TBPlan(**payload) - return TBPlan.model_construct(**payload) - - -__all__ = ["CalendarEvent", "Timebox", "tb_plan_to_timebox", "timebox_to_tb_plan"] diff --git a/src/fateforger/agents/timeboxing/tool_result_models.py b/src/fateforger/agents/timeboxing/tool_result_models.py deleted file mode 100644 index 5d8def1e..00000000 --- a/src/fateforger/agents/timeboxing/tool_result_models.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Typed tool result models for channel-agnostic orchestration outputs.""" - -from __future__ import annotations - -from enum import Enum -from typing import Any, Literal - -from pydantic import BaseModel, Field - - -class InteractionMode(str, Enum): - """Supported interaction render targets.""" - - TEXT = "text" - SLACK = "slack" - - -class MemoryConstraintItem(BaseModel): - """Constraint row normalized for memory tool responses and UI serialization.""" - - uid: str - name: str = "Constraint" - description: str = "" - status: str | None = None - scope: str | None = None - necessity: str | None = None - source: str | None = None - confidence: float | None = None - needs_confirmation: bool = False - used_this_session: bool = False - - @classmethod - def from_payload(cls, payload: dict[str, Any]) -> "MemoryConstraintItem | None": - """Build an item from either a query row or a nested `constraint_record` payload.""" - if not isinstance(payload, dict): - return None - constraint = payload.get("constraint_record") - if not isinstance(constraint, dict): - constraint = payload - lifecycle = constraint.get("lifecycle") if isinstance(constraint, dict) else {} - lifecycle = lifecycle if isinstance(lifecycle, dict) else {} - selector = constraint.get("selector") if isinstance(constraint, dict) else {} - selector = selector if isinstance(selector, dict) else {} - hints = constraint.get("hints") if isinstance(constraint, dict) else {} - hints = hints if isinstance(hints, dict) else {} - uid = str( - payload.get("uid") - or lifecycle.get("uid") - or hints.get("uid") - or "" - ).strip() - if not uid: - return None - - confidence_raw = payload.get("confidence", constraint.get("confidence")) - confidence: float | None = None - if confidence_raw is not None: - try: - confidence = float(confidence_raw) - except (TypeError, ValueError): - confidence = None - needs_confirmation = bool( - payload.get("needs_confirmation") - or selector.get("needs_confirmation") - or hints.get("needs_confirmation") - ) - used_this_session = bool( - payload.get("used_this_session") - or hints.get("used_this_session") - ) - if confidence is not None and confidence < 0.7: - needs_confirmation = True - return cls( - uid=uid, - name=str(payload.get("name") or constraint.get("name") or "Constraint").strip() - or "Constraint", - description=str( - payload.get("description") or constraint.get("description") or "" - ).strip(), - status=_as_optional_text(payload.get("status", constraint.get("status"))), - scope=_as_optional_text(payload.get("scope", constraint.get("scope"))), - necessity=_as_optional_text( - payload.get("necessity", constraint.get("necessity")) - ), - source=_as_optional_text(payload.get("source", constraint.get("source"))), - confidence=confidence, - needs_confirmation=needs_confirmation, - used_this_session=used_this_session, - ) - - -class MemoryToolResult(BaseModel): - """Typed result envelope emitted by memory CRUD/search tool actions.""" - - action: Literal["list", "get", "update", "archive", "supersede"] - ok: bool - message: str | None = None - error: str | None = None - uid: str | None = None - count: int | None = None - constraints: list[MemoryConstraintItem] = Field(default_factory=list) - - def to_tool_payload(self) -> dict[str, Any]: - """Serialize for LLM tool-return transport.""" - payload = self.model_dump(exclude_none=True) - payload["constraints"] = [item.model_dump(exclude_none=True) for item in self.constraints] - return payload - - -def _as_optional_text(value: Any) -> str | None: - text = str(value).strip() if value is not None else "" - return text or None - - -__all__ = [ - "InteractionMode", - "MemoryConstraintItem", - "MemoryToolResult", -] diff --git a/src/fateforger/agents/timeboxing/tool_result_presenter.py b/src/fateforger/agents/timeboxing/tool_result_presenter.py deleted file mode 100644 index e7891444..00000000 --- a/src/fateforger/agents/timeboxing/tool_result_presenter.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Serialize typed tool results at the interaction boundary.""" - -from __future__ import annotations - -from typing import Any - -from pydantic import BaseModel, Field - -from .tool_result_models import InteractionMode, MemoryToolResult - - -class InteractionContext(BaseModel): - """Channel context used to serialize tool output for the current interaction.""" - - mode: InteractionMode - user_id: str - thread_ts: str - - -class MemoryToolPresentation(BaseModel): - """Presentation envelope built from a typed memory tool result.""" - - payload: dict[str, Any] - blocks: list[dict[str, Any]] = Field(default_factory=list) - text_update: str | None = None - - -def present_memory_tool_result( - *, - result: MemoryToolResult, - context: InteractionContext, -) -> MemoryToolPresentation: - """Serialize the typed result for the active channel without mutating session state.""" - payload = result.to_tool_payload() - match context.mode: - case InteractionMode.SLACK: - from fateforger.slack_bot.constraint_review import ( - build_memory_tool_result_blocks, - ) - - blocks = build_memory_tool_result_blocks( - result, - thread_ts=context.thread_ts, - user_id=context.user_id, - ) - return MemoryToolPresentation(payload=payload, blocks=blocks) - case _: - return MemoryToolPresentation(payload=payload, text_update=result.message) - - -__all__ = [ - "InteractionContext", - "MemoryToolPresentation", - "present_memory_tool_result", -] diff --git a/src/fateforger/agents/timeboxing/toon_views.py b/src/fateforger/agents/timeboxing/toon_views.py deleted file mode 100644 index 3133f2ff..00000000 --- a/src/fateforger/agents/timeboxing/toon_views.py +++ /dev/null @@ -1,95 +0,0 @@ -"""TOON prompt views for timeboxing. - -This module defines *minimal* column sets for injecting structured lists into LLM prompts. -The goal is to avoid dumping full Pydantic/SQLModel JSON into prompts while still preserving -the information the stage agent needs. -""" - -from __future__ import annotations - -from typing import Any - -from fateforger.agents.schedular.models.calendar import CalendarEvent -from fateforger.agents.timeboxing.contracts import Immovable, TaskCandidate -from fateforger.agents.timeboxing.preferences import Constraint - - -def immovables_rows(items: list[Immovable]) -> list[dict[str, Any]]: - """Return minimal TOON rows for immovables.""" - return [{"title": i.title, "start": i.start, "end": i.end} for i in items] - - -def tasks_rows(items: list[TaskCandidate]) -> list[dict[str, Any]]: - """Return minimal TOON rows for task candidates.""" - return [ - { - "title": t.title, - "block_count": t.block_count, - "duration_min": t.duration_min, - "due": t.due, - "importance": t.importance, - } - for t in items - ] - - -def constraints_rows(items: list[Constraint | dict]) -> list[dict[str, Any]]: - """Return minimal TOON rows for constraints. - - Accepts both ``Constraint`` instances and plain dicts (e.g. from deserialized - LLM context payloads) so that prompt-context injection is uniform regardless - of whether the upstream code holds live ORM objects or raw dicts. - - We intentionally avoid DB-only fields and large nested dicts (selector/hints). - """ - result: list[dict[str, Any]] = [] - for c in items: - if isinstance(c, dict): - result.append( - { - "name": c.get("name", ""), - "necessity": c.get("necessity", ""), - "scope": c.get("scope", ""), - "status": c.get("status", ""), - "source": c.get("source", ""), - "description": c.get("description", ""), - } - ) - else: - result.append( - { - "name": c.name, - "necessity": getattr(c.necessity, "value", c.necessity), - "scope": getattr(c.scope, "value", c.scope), - "status": getattr(c.status, "value", c.status), - "source": getattr(c.source, "value", c.source), - "description": c.description, - } - ) - return result - - -def timebox_events_rows(items: list[CalendarEvent]) -> list[dict[str, Any]]: - """Return compact TOON rows for timebox events (for summary/review stages).""" - rows: list[dict[str, Any]] = [] - for ev in items: - rows.append( - { - "type": getattr(ev.event_type, "value", None), - "summary": ev.summary, - "ST": ev.start_time.strftime("%H:%M") if ev.start_time else "", - "ET": ev.end_time.strftime("%H:%M") if ev.end_time else "", - "DT": ev.duration.total_seconds() if ev.duration else "", - "AP": "true" if getattr(ev, "anchor_prev", True) else "false", - "location": ev.location or "", - } - ) - return rows - - -__all__ = [ - "constraints_rows", - "immovables_rows", - "tasks_rows", - "timebox_events_rows", -] diff --git a/src/fateforger/core/README.md b/src/fateforger/core/README.md index 58c73681..acc67415 100644 --- a/src/fateforger/core/README.md +++ b/src/fateforger/core/README.md @@ -5,6 +5,15 @@ - Implemented: Graphiti is the active durable-memory runtime path when `TIMEBOXING_MEMORY_BACKEND=graphiti`. - Tested: the VS Code local Slack bot debug tasks now bring up `neo4j` and `graphiti-mcp`, and the Python debug launch config pins the local Neo4j endpoint while inheriting the Graphiti MCP URL from `.env`. +The `TIMEBOXING_MEMORY_BACKEND` setting name predates the 2026-09-09 legacy-agent +retirement (`refactor: retire TimeboxingFlowAgent and the 34 modules only it +reached`); the timeboxing coordinator it names is deleted. The Graphiti startup +checks below now serve `runtime.py` itself and `fateforger.agents.tasks.defaults_memory` +(tasks' defaults memory), which read `graphiti_constraint_memory.py` / +`constraint_record_memory.py` through `settings.timeboxing_memory_backend` β€” +see `src/fateforger/agents/timeboxing/README.md` for that backend's current +callers. + Runtime startup logs include git provenance fields (`branch`, `commit`, `tag`, `dirty`) to help correlate observed behavior with the exact running code revision. When `TIMEBOXING_MEMORY_BACKEND=graphiti` is active, startup also logs the durable-memory runtime identity: diff --git a/src/fateforger/core/logging.py b/src/fateforger/core/logging.py deleted file mode 100644 index 66c8c0cc..00000000 --- a/src/fateforger/core/logging.py +++ /dev/null @@ -1,9 +0,0 @@ -import logging - - -def get_logger(name: str) -> logging.Logger: - """Return application logger.""" - logger = logging.getLogger(f"fateforger.{name}") - if not logger.handlers: - logging.basicConfig(level=logging.INFO) - return logger diff --git a/src/fateforger/core/runtime.py b/src/fateforger/core/runtime.py index 7cd731dd..81942c3d 100644 --- a/src/fateforger/core/runtime.py +++ b/src/fateforger/core/runtime.py @@ -28,7 +28,6 @@ from fateforger.agents.revisor.agent import RevisorAgent from fateforger.agents.schedular.agent import PlannerAgent from fateforger.agents.tasks import TasksAgent -from fateforger.agents.timeboxing.agent import TimeboxingFlowAgent from fateforger.agents.timeboxing.durable_constraint_store import ( build_durable_constraint_store, ) @@ -795,11 +794,6 @@ async def dispatch_planning(reminder: PlanningReminder) -> None: "planner_agent", lambda: PlannerAgent("planner_agent", haunt=haunt), ) - await TimeboxingFlowAgent.register( - runtime, - "timeboxing_agent", - lambda: TimeboxingFlowAgent("timeboxing_agent"), - ) await RevisorAgent.register( runtime, "revisor_agent", diff --git a/src/fateforger/core/slack.py b/src/fateforger/core/slack.py deleted file mode 100644 index 8c17183b..00000000 --- a/src/fateforger/core/slack.py +++ /dev/null @@ -1,23 +0,0 @@ -from __future__ import annotations - - -async def schedule_dm(client, channel: str, text: str, post_at: int) -> str: - response = await client.chat_scheduleMessage( - channel=channel, - text=text, - post_at=post_at, - ) - scheduled_id = response.get("scheduled_message_id") - if not scheduled_id: - raise RuntimeError("Slack schedule failed: missing scheduled_message_id") - return scheduled_id - - -async def delete_scheduled(client, channel: str, scheduled_id: str) -> None: - await client.chat_deleteScheduledMessage( - channel=channel, - scheduled_message_id=scheduled_id, - ) - - -__all__ = ["schedule_dm", "delete_scheduled"] diff --git a/src/fateforger/llm/toon.py b/src/fateforger/llm/toon.py deleted file mode 100644 index 95fa2390..00000000 --- a/src/fateforger/llm/toon.py +++ /dev/null @@ -1,106 +0,0 @@ -"""TOON-style tabular prompt encoding helpers. - -The upstream `toon-format` package in this repo is currently a stub (encoder not implemented). -This module provides a small, deterministic encoder that follows the core TOON conventions: - -- Header: `[N]{k1,k2,...}:` -- Records: one row per item, values in that exact key order - -This is used to inject structured *lists* (constraints, tasks, events) into LLM prompts -without dumping large JSON blobs. -""" - -from __future__ import annotations - -from datetime import date, datetime, time, timedelta -from enum import Enum -from typing import Any, Iterable, Mapping, Sequence - -from pydantic import BaseModel - - -def toon_encode( - *, - name: str, - rows: Sequence[Mapping[str, Any] | BaseModel], - fields: Sequence[str], - delimiter: str = ",", -) -> str: - """Encode uniform rows into a TOON-style table string. - - Args: - name: Logical name of the table (e.g. "constraints", "tasks"). - rows: Sequence of dicts or Pydantic models. - fields: Ordered field names to emit as columns. - delimiter: Column delimiter (default comma). - - Returns: - A TOON-style string with a header and N record rows. - """ - normalized: list[dict[str, Any]] = [] - for row in rows: - if isinstance(row, BaseModel): - normalized.append(row.model_dump(mode="json")) - elif isinstance(row, Mapping): - normalized.append(dict(row)) - else: - normalized.append({}) - - header = f"{name}[{len(normalized)}]" + "{" + ",".join(fields) + "}:" - if not normalized: - return header - - lines: list[str] = [header] - for row in normalized: - values = [_toon_scalar(row.get(field)) for field in fields] - lines.append(delimiter.join(_toon_escape(v, delimiter=delimiter) for v in values)) - return "\n".join(lines) - - -def _toon_scalar(value: Any) -> str: - """Convert a python value into a compact scalar string for TOON tables.""" - if value is None: - return "" - if isinstance(value, Enum): - return str(value.value) - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, (int, float)): - return str(value) - if isinstance(value, date) and not isinstance(value, datetime): - return value.isoformat() - if isinstance(value, datetime): - return value.isoformat() - if isinstance(value, time): - return value.strftime("%H:%M") - if isinstance(value, timedelta): - return str(value.total_seconds()) - if isinstance(value, (list, tuple, set)): - return "|".join(_toon_scalar(item) for item in value) - return str(value) - - -def _toon_escape(value: str, *, delimiter: str) -> str: - """Escape a scalar for safe TOON/CSV-style parsing. - - We quote only when needed: - - contains delimiter/newline/quote - - has leading/trailing whitespace - """ - if value == "": - return "" - needs_quote = ( - delimiter in value - or "\n" in value - or "\r" in value - or '"' in value - or value != value.strip() - ) - if not needs_quote: - return value - escaped = value.replace('"', '""') - return f"\"{escaped}\"" - - -__all__ = ["toon_encode"] - diff --git a/src/fateforger/slack_bot/AGENTS.md b/src/fateforger/slack_bot/AGENTS.md index 4163f276..31614931 100644 --- a/src/fateforger/slack_bot/AGENTS.md +++ b/src/fateforger/slack_bot/AGENTS.md @@ -55,13 +55,13 @@ - `StageReviewCommitNode` emits a `pending_submit` state; no auto-submit in the node path. - `PresenterNode` attaches review action blocks (confirm/cancel). -- Slack action handlers in `handlers.py`: - - `ff_timebox_confirm_submit` - - `ff_timebox_cancel_submit` - - `ff_timebox_undo_submit` -- Action bridge lives in `timeboxing_submit.py` and dispatches typed messages to `timeboxing_agent`. -- On confirm: call `CalendarSubmitter.submit_plan()`. -- On undo: call `CalendarSubmitter.undo_transaction()` using session-backed transaction state. +- Retired at the Slack layer (2026-09-09): `ff_timebox_confirm_submit`, `ff_timebox_cancel_submit` + and `ff_timebox_undo_submit` used to reach `timeboxing_agent` through a dedicated action + bridge that called `CalendarSubmitter.submit_plan()` / `.undo_transaction()`. That bridge is + gone; `handlers.py` now answers a press on any of the seven legacy card action ids with the + single "this flow is retired" handler in `retired_cards.py`, so nothing in this section + reaches Slack any more even though the sync-engine code paths themselves still exist pending + the agent's own deletion. ## Testing diff --git a/src/fateforger/slack_bot/README.md b/src/fateforger/slack_bot/README.md index 2a6d281f..1791b961 100644 --- a/src/fateforger/slack_bot/README.md +++ b/src/fateforger/slack_bot/README.md @@ -45,8 +45,8 @@ Socket Mode Slack bot that routes user interactions to specialist agents. Built | File | Responsibility | |------|---------------| | `timeboxing_commit.py` | Stage 0 "commit day" Slack UI: day picker + start button. Handles user day-selection before a timeboxing session begins. Action IDs: `FF_TIMEBOX_COMMIT_*`. | -| `timeboxing_submit.py` | Stage 5 submit/cancel/undo Slack action bridge. Parses button metadata and dispatches typed submit/undo messages to `timeboxing_agent`. | -| `constraint_review.py` | Block Kit modals and action payloads for reviewing/editing timeboxing constraints extracted from conversations. | +| `retired_cards.py` | One handler for the seven legacy card action IDs (Stage 5 submit/cancel/undo, deterministic stage proceed/back/redo/cancel). The two modules that used to dispatch these to `timeboxing_agent` are gone (retired 2026-09-09); a press now just rewrites the card to say the flow is retired. | +| ~~`constraint_review.py`~~ | Removed (`6f93212`): its Block Kit modals had no writer left after the legacy agent retired β€” its only writers were `agent.py` and a review modal only it posted β€” so the constraint store it read from sat permanently empty; the surface is gone rather than left dispatching to a dead table. | | `stage_context.py` | Stage 1 context surfaces as typed values: `ContextPanel` (two blocks, counts by anchor group) and `ContextFold` (the rules modal), built from the session snapshot alone; ranking by what changed and what is uncertain first. | | `stage_card_registry.py` | Remembers each session's live stage card and context panel; receipts the card on transition, edits the panel in place, retires it when the session ends. | @@ -171,11 +171,8 @@ Button/action callbacks registered in `handlers.py`: | `FF_EVENT_*` | `planning.py` | Calendar slot editing | | `ff_tasks_*` | `handlers.py` + `task_cards.py` | Due-task overview/view-all, per-task details modal, modal-driven task title updates | | `ff_open_google_calendar_event` / `open_event_url` | `handlers.py` | Ack URL-button clicks so Slack opens event links without action errors | -| `timeboxing_constraint_review` | `handlers.py` + `constraint_review.py` | Open single-constraint deny/edit modal | -| `ff_timeboxing_constraint_review_all` (legacy: `timeboxing_constraint_review_all`) | `handlers.py` + `constraint_review.py` | Open full constraint list modal | -| `ff_timebox_confirm_submit` | `timeboxing_submit.py` | Submit Stage 5 plan to calendar | -| `ff_timebox_cancel_submit` | `timeboxing_submit.py` | Cancel pending Stage 5 submit and return to refine | -| `ff_timebox_undo_submit` | `timeboxing_submit.py` | Undo latest Stage 5 submission | +| ~~`timeboxing_constraint_review`~~ / ~~`ff_timeboxing_constraint_review_all`~~ (legacy: `timeboxing_constraint_review_all`) | β€” | Removed (`6f93212`): opened `constraint_review.py`'s single-constraint and full-list modals, which are gone for the same reason β€” no writer left after the legacy agent retired | +| `ff_timebox_confirm_submit`, `ff_timebox_cancel_submit`, `ff_timebox_undo_submit`, `ff_timebox_stage_proceed`, `ff_timebox_stage_back`, `ff_timebox_stage_redo`, `ff_timebox_stage_cancel` | `retired_cards.py` | Retired (2026-09-09): these seven ids only appear on cards the legacy agent posted; a press rewrites the card in place rather than dispatching anywhere | | `ff_harness_approve` | `handlers.py` + `timebox_candidate.py` | Commit the exact user-owned harness candidate once | | `ff_harness_undo` | `handlers.py` + `tmbx_client.py` | Reverse the reported tmbx transaction directly | | `ff_timebox_show_rules` | `handlers.py` + `timeboxing_cards.py` | Open the Stage 1 rules modal from the context panel (reads state, changes nothing) | diff --git a/src/fateforger/slack_bot/constraint_review.py b/src/fateforger/slack_bot/constraint_review.py deleted file mode 100644 index 27de5328..00000000 --- a/src/fateforger/slack_bot/constraint_review.py +++ /dev/null @@ -1,517 +0,0 @@ -"""Slack modal helpers for timeboxing constraint review.""" - -from __future__ import annotations - -from typing import Any, Iterable -from urllib.parse import parse_qs, urlencode - -import ultimate_notion as uno -from pydantic import BaseModel, ConfigDict, ValidationError - -from fateforger.agents.timeboxing.tool_result_models import ( - MemoryToolResult, -) -from fateforger.agents.timeboxing.preferences import ( - ConstraintScope, - ConstraintStatus, -) - -CONSTRAINT_ROW_REVIEW_ACTION_ID = "timeboxing_constraint_review" -FF_CONSTRAINT_REVIEW_ALL_ACTION_ID = "ff_timeboxing_constraint_review_all" -LEGACY_CONSTRAINT_REVIEW_ALL_ACTION_ID = "timeboxing_constraint_review_all" -CONSTRAINT_REVIEW_ALL_ACTION_ID = FF_CONSTRAINT_REVIEW_ALL_ACTION_ID -CONSTRAINT_REVIEW_VIEW_CALLBACK_ID = "timeboxing_constraint_review_modal" -CONSTRAINT_REVIEW_LIST_VIEW_CALLBACK_ID = "timeboxing_constraint_review_list_modal" -CONSTRAINT_DECISION_ACTION_ID = "constraint_decision" -CONSTRAINT_DESCRIPTION_ACTION_ID = "constraint_description" - - -def _coerce_option_value(value: object | None) -> str: - """Coerce UNO/Pydantic/Enum-ish values into a lowercase string label.""" - if value is None: - return "" - name = getattr(value, "name", None) - if isinstance(name, str) and name: - return name.strip().lower() - enum_value = getattr(value, "value", None) - if isinstance(enum_value, str) and enum_value: - return enum_value.strip().lower() - return str(value).strip().lower() - - -class ConstraintReviewItem(BaseModel): - """Pydantic DTO for rendering Slack constraint review UI from UNO or session models.""" - - model_config = ConfigDict( - extra="ignore", - from_attributes=True, - frozen=True, - arbitrary_types_allowed=True, - ) - - id: int | str | None = None - uid: str | None = None - name: str | None = None - description: str | None = None - necessity: object | None = None - status: object | None = None - scope: object | None = None - source: object | None = None - used_this_session: bool = False - - @classmethod - def coerce(cls, constraint: "ConstraintReviewItem | uno.Page | object") -> "ConstraintReviewItem": - """Build a review DTO from a session constraint or a UNO page-like object.""" - if isinstance(constraint, cls): - return constraint - try: - return cls.model_validate(constraint, from_attributes=True) - except ValidationError: - if isinstance(constraint, dict): - return cls.model_validate(constraint) - raise - - def constraint_id(self) -> str: - """Return the best-effort identifier for Slack metadata.""" - raw = self.id if self.id is not None else (self.uid or "") - return str(raw or "") - - def necessity_value(self) -> str: - """Return the necessity label (e.g. 'must', 'should').""" - return _coerce_option_value(self.necessity) or "must" - - def scope_enum(self) -> ConstraintScope: - """Return a ConstraintScope derived from UNO/enum/string scope values.""" - scope_value = _coerce_option_value(self.scope) - if scope_value == ConstraintScope.PROFILE.value: - return ConstraintScope.PROFILE - if scope_value == ConstraintScope.DATESPAN.value: - return ConstraintScope.DATESPAN - return ConstraintScope.SESSION - - def status_enum(self) -> ConstraintStatus | None: - """Return a ConstraintStatus derived from UNO/enum/string status values.""" - if isinstance(self.status, ConstraintStatus): - return self.status - status_value = _coerce_option_value(self.status) - if status_value == ConstraintStatus.LOCKED.value: - return ConstraintStatus.LOCKED - if status_value == ConstraintStatus.PROPOSED.value: - return ConstraintStatus.PROPOSED - if status_value == ConstraintStatus.DECLINED.value: - return ConstraintStatus.DECLINED - return None - - -def build_constraint_row_blocks( - constraints: Iterable[object], - *, - thread_ts: str, - user_id: str, - limit: int = 20, - button_text: str = "Review", -) -> list[dict[str, Any]]: - """Build single-row constraint blocks with a review button.""" - items = [ConstraintReviewItem.coerce(constraint) for constraint in constraints] - blocks: list[dict[str, Any]] = [] - for constraint in items[:limit]: - if not constraint.constraint_id(): - continue - value = encode_metadata( - { - "constraint_id": constraint.constraint_id(), - "thread_ts": thread_ts, - "user_id": user_id, - } - ) - blocks.append( - { - "type": "section", - "text": {"type": "mrkdwn", "text": _constraint_row_text(constraint)}, - "accessory": { - "type": "button", - "action_id": CONSTRAINT_ROW_REVIEW_ACTION_ID, - "text": {"type": "plain_text", "text": button_text}, - "value": value, - }, - } - ) - remaining = len(items) - len(blocks) - if remaining > 0: - blocks.append( - { - "type": "context", - "elements": [ - {"type": "mrkdwn", "text": f"...and {remaining} more constraints."} - ], - } - ) - return blocks - - -def build_constraint_review_all_action_block( - *, - thread_ts: str, - user_id: str, - count: int, -) -> dict[str, Any]: - """Build a button that opens a modal with the complete constraint list.""" - value = encode_metadata( - { - "thread_ts": thread_ts, - "user_id": user_id, - } - ) - return { - "type": "actions", - "elements": [ - { - "type": "button", - "action_id": CONSTRAINT_REVIEW_ALL_ACTION_ID, - "text": {"type": "plain_text", "text": f"Review all constraints ({count})"}, - "value": value, - } - ], - } - - -def build_memory_tool_result_blocks( - result: MemoryToolResult, - *, - thread_ts: str, - user_id: str, - limit: int = 8, -) -> list[dict[str, Any]]: - """Serialize a typed memory-tool result into Slack card blocks.""" - if not result.ok and not result.message and not result.error: - return [] - title = _single_line(result.message or _default_memory_result_message(result)) - blocks: list[dict[str, Any]] = [ - {"type": "section", "text": {"type": "mrkdwn", "text": f"*Memory*\n{title}"}} - ] - if result.error: - blocks.append( - { - "type": "context", - "elements": [{"type": "mrkdwn", "text": f":warning: {result.error}"}], - } - ) - if not result.constraints: - return blocks - rows = [ - { - "uid": item.uid, - "name": item.name, - "description": item.description, - "necessity": item.necessity or "should", - "status": item.status or "proposed", - "scope": item.scope or "session", - "source": item.source or "unknown", - "used_this_session": bool(item.used_this_session), - } - for item in result.constraints[:limit] - ] - blocks.append({"type": "divider"}) - blocks.extend( - build_constraint_row_blocks( - rows, - thread_ts=thread_ts, - user_id=user_id, - limit=limit, - button_text="Deny / Edit", - ) - ) - pending = [item.name for item in result.constraints if item.needs_confirmation] - if pending: - preview = ", ".join(_single_line(name or "Constraint") for name in pending[:3]) - more = f" (+{len(pending) - 3})" if len(pending) > 3 else "" - blocks.append( - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": f":information_source: Needs confirmation: {preview}{more}", - } - ], - } - ) - return blocks - - -def build_constraint_review_list_view( - constraints: Iterable[object], - *, - channel_id: str, - thread_ts: str, - user_id: str, - limit: int = 20, -) -> dict[str, Any]: - """Build a modal listing active constraints with per-row deny/edit controls.""" - items = [ConstraintReviewItem.coerce(constraint) for constraint in constraints] - blocks: list[dict[str, Any]] = [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "*Constraint review*\nSelect any row to deny or edit it.", - }, - } - ] - if items: - blocks.append({"type": "divider"}) - blocks.extend( - build_constraint_row_blocks( - items, - thread_ts=thread_ts, - user_id=user_id, - limit=limit, - button_text="Deny / Edit", - ) - ) - else: - blocks.append( - { - "type": "context", - "elements": [{"type": "mrkdwn", "text": "No active constraints found."}], - } - ) - return { - "type": "modal", - "callback_id": CONSTRAINT_REVIEW_LIST_VIEW_CALLBACK_ID, - "private_metadata": encode_metadata( - { - "channel_id": channel_id, - "thread_ts": thread_ts, - "user_id": user_id, - } - ), - "title": {"type": "plain_text", "text": "Constraints"}, - "close": {"type": "plain_text", "text": "Close"}, - "blocks": blocks, - } - - -def build_constraint_review_view( - constraint: object, - *, - channel_id: str, - thread_ts: str, - user_id: str, -) -> dict[str, Any]: - """Build the Slack modal for reviewing a single constraint.""" - item = ConstraintReviewItem.coerce(constraint) - metadata = encode_metadata( - { - "channel_id": channel_id, - "thread_ts": thread_ts, - "user_id": user_id, - "constraint_id": item.constraint_id(), - } - ) - description = item.description or "" - name = _single_line(item.name or "Constraint") - scope_label = _constraint_scope_label(item) - status_option = "decline" if item.status_enum() == ConstraintStatus.DECLINED else "accept" - return { - "type": "modal", - "callback_id": CONSTRAINT_REVIEW_VIEW_CALLBACK_ID, - "private_metadata": metadata, - "title": {"type": "plain_text", "text": "Constraint review"}, - "submit": {"type": "plain_text", "text": "Save"}, - "close": {"type": "plain_text", "text": "Cancel"}, - "blocks": [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": ( - f"*{name}* ({item.necessity_value()})\n" - f"_Scope: {scope_label}_" - ), - }, - }, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": ( - "Edits here apply to this session unless you say " - '"always" or "from now on" in chat.' - ), - } - ], - }, - { - "type": "input", - "block_id": "constraint_description_block", - "label": {"type": "plain_text", "text": "Description"}, - "element": { - "type": "plain_text_input", - "action_id": CONSTRAINT_DESCRIPTION_ACTION_ID, - "multiline": True, - "initial_value": description, - }, - }, - { - "type": "input", - "block_id": "constraint_decision_block", - "label": {"type": "plain_text", "text": "Decision"}, - "element": { - "type": "radio_buttons", - "action_id": CONSTRAINT_DECISION_ACTION_ID, - "initial_option": ( - { - "text": { - "type": "plain_text", - "text": "Accept", - }, - "value": "accept", - } - if status_option == "accept" - else { - "text": { - "type": "plain_text", - "text": "Decline", - }, - "value": "decline", - } - ), - "options": [ - { - "text": {"type": "plain_text", "text": "Accept"}, - "value": "accept", - }, - { - "text": {"type": "plain_text", "text": "Decline"}, - "value": "decline", - }, - ], - }, - }, - ], - } - - -def parse_constraint_review_submission( - state_values: dict[str, Any], -) -> tuple[ConstraintStatus | None, str | None]: - """Parse decision + description from a constraint review modal submission.""" - decision = ( - state_values.get("constraint_decision_block", {}) - .get(CONSTRAINT_DECISION_ACTION_ID, {}) - .get("selected_option") - ) - status = _status_from_value((decision or {}).get("value")) - description = ( - state_values.get("constraint_description_block", {}) - .get(CONSTRAINT_DESCRIPTION_ACTION_ID, {}) - .get("value") - ) - cleaned = (description or "").strip() - return status, cleaned or None - - -def _status_from_value(value: str | None) -> ConstraintStatus | None: - """Translate UI decision values into constraint statuses.""" - if value == "accept": - return ConstraintStatus.LOCKED - if value == "decline": - return ConstraintStatus.DECLINED - return None - - -def _single_line(text: str) -> str: - """Collapse whitespace into a single-line string.""" - return " ".join((text or "").split()) - - -def _constraint_row_text(constraint: ConstraintReviewItem) -> str: - """Render a single-line constraint description for Slack row blocks.""" - name = _single_line(constraint.name or "Constraint") - description = _single_line(constraint.description or "") - scope_label = _constraint_scope_label(constraint) - status_label = _constraint_status_label(constraint) - source_label = _single_line(str(constraint.source or "unknown")).lower() - used_label = "used: this session" if bool(constraint.used_this_session) else None - meta_parts = [ - f"scope: {scope_label}", - f"status: {status_label}", - f"source: {source_label}", - ] - if used_label: - meta_parts.append(used_label) - meta_text = "; ".join(meta_parts) - if description: - return f"*{name}* - {description} _({meta_text})_" - return f"*{name}* _({meta_text})_" - - -def _constraint_scope_label(constraint: ConstraintReviewItem) -> str: - """Return a human-friendly scope label for the constraint.""" - scope = constraint.scope_enum() - if scope == ConstraintScope.PROFILE: - return "profile" - if scope == ConstraintScope.DATESPAN: - return "datespan" - return "session" - - -def _constraint_status_label(constraint: ConstraintReviewItem) -> str: - """Return a human-friendly status label for the constraint.""" - status = constraint.status_enum() - if status == ConstraintStatus.LOCKED: - return "locked" - if status == ConstraintStatus.DECLINED: - return "declined" - return "proposed" - - -def _default_memory_result_message(result: MemoryToolResult) -> str: - action = result.action - count = int(result.count or len(result.constraints) or 0) - if action == "list": - return f"Found {count} remembered constraint(s)." - if action == "get": - return "Loaded remembered constraint." - if action == "update": - return "Updated remembered constraint." - if action == "archive": - return "Archived remembered constraint." - if action == "supersede": - return "Superseded remembered constraint." - return "Memory action completed." - - -def encode_metadata(values: dict[str, str]) -> str: - """Encode modal metadata into a querystring value.""" - return urlencode(values) - - -def decode_metadata(payload: str) -> dict[str, str]: - """Decode modal metadata from a querystring payload.""" - if not payload: - return {} - parsed = parse_qs(payload, keep_blank_values=True) - return {key: value[0] for key, value in parsed.items()} - - -__all__ = [ - "CONSTRAINT_ROW_REVIEW_ACTION_ID", - "FF_CONSTRAINT_REVIEW_ALL_ACTION_ID", - "LEGACY_CONSTRAINT_REVIEW_ALL_ACTION_ID", - "CONSTRAINT_REVIEW_ALL_ACTION_ID", - "CONSTRAINT_REVIEW_LIST_VIEW_CALLBACK_ID", - "CONSTRAINT_REVIEW_VIEW_CALLBACK_ID", - "CONSTRAINT_DECISION_ACTION_ID", - "CONSTRAINT_DESCRIPTION_ACTION_ID", - "build_constraint_review_all_action_block", - "build_constraint_review_list_view", - "build_constraint_row_blocks", - "build_memory_tool_result_blocks", - "build_constraint_review_view", - "parse_constraint_review_submission", - "encode_metadata", - "decode_metadata", -] diff --git a/src/fateforger/slack_bot/dsh_commit_gate_hook.py b/src/fateforger/slack_bot/dsh_commit_gate_hook.py index 822e0011..7ee4f086 100644 --- a/src/fateforger/slack_bot/dsh_commit_gate_hook.py +++ b/src/fateforger/slack_bot/dsh_commit_gate_hook.py @@ -1,8 +1,8 @@ """A DeepSeek Harness ``PreToolUse`` hook that will not let a plan reach the calendar unless a human said so. -The harness path has no review stage β€” unlike the legacy flow, which lost its -gate in one commit, ``/dsh`` was born without one. Every plan it commits is +The harness gate denies by default; stages 4 and 5 are the review and commit +cards, and only an explicit approval press opens it. Every plan it commits is recorded in the journal as ``ACCEPTED``, and that disposition is a training label feeding the constraint memory server. So an unattended commit does not merely change a calendar: it teaches the system that Hugo approved a day nobody diff --git a/src/fateforger/slack_bot/handlers.py b/src/fateforger/slack_bot/handlers.py index 94fc0259..e87fe8a9 100644 --- a/src/fateforger/slack_bot/handlers.py +++ b/src/fateforger/slack_bot/handlers.py @@ -3,10 +3,8 @@ import asyncio import json import logging -import os -import threading import time -from collections.abc import Awaitable, Callable +from collections.abc import Callable from dataclasses import dataclass from datetime import UTC, datetime from time import perf_counter @@ -31,13 +29,6 @@ TurnRequest, ) from fateforger.agents.timeboxing.feedback import feedback_facts -from fateforger.agents.timeboxing.messages import StartTimeboxing, TimeboxingUserReply -from fateforger.agents.timeboxing.preferences import ( - Constraint, - ConstraintStatus, - ConstraintStore, - ensure_constraint_schema, -) from fateforger.agents.timeboxing.readiness import TimeboxRequirements from fateforger.agents.timeboxing.session_contracts import ( ApproveArtifact, @@ -53,17 +44,6 @@ from fateforger.core.config import settings from fateforger.core.logging_config import observe_stage_duration, record_error from fateforger.slack_bot.bootstrap import ensure_workspace_ready -from fateforger.slack_bot.constraint_review import ( - CONSTRAINT_REVIEW_VIEW_CALLBACK_ID, - CONSTRAINT_ROW_REVIEW_ACTION_ID, - FF_CONSTRAINT_REVIEW_ALL_ACTION_ID, - LEGACY_CONSTRAINT_REVIEW_ALL_ACTION_ID, - build_constraint_review_list_view, - build_constraint_review_view, - build_constraint_row_blocks, - decode_metadata, - parse_constraint_review_submission, -) from fateforger.slack_bot.messages import ( SLACK_MAX_BLOCK_TEXT_CHARS, SLACK_MAX_BLOCKS, @@ -100,6 +80,7 @@ from fateforger.slack_bot.progress_events import ( ProgressStatus as TimeboxProgressStatus, ) +from fateforger.slack_bot import retired_cards from fateforger.slack_bot.reply_guard import agent_reply_text from fateforger.slack_bot.stage_card_registry import StageCardRegistry, receipt_body, receipt_label from fateforger.slack_bot.stage_context import context_fold @@ -138,8 +119,8 @@ FF_TIMEBOX_COMMIT_DAY_SELECT_ACTION_ID, FF_TIMEBOX_COMMIT_START_ACTION_ID, TimeboxCommitMeta, - TimeboxingCommitCoordinator, day_type_action_id, + decode_metadata, format_relative_day_label, ) from fateforger.slack_bot.timeboxing_host import ( @@ -153,21 +134,6 @@ intent_from_artifact_action, intent_from_date_action, ) -from fateforger.slack_bot.timeboxing_stage_actions import ( - FF_TIMEBOX_STAGE_BACK_ACTION_ID, - FF_TIMEBOX_STAGE_CANCEL_ACTION_ID, - FF_TIMEBOX_STAGE_PROCEED_ACTION_ID, - FF_TIMEBOX_STAGE_REDO_ACTION_ID, - TimeboxingStageActionCoordinator, - TimeboxingStageActionPayload, -) -from fateforger.slack_bot.timeboxing_submit import ( - FF_TIMEBOX_CANCEL_SUBMIT_ACTION_ID, - FF_TIMEBOX_CONFIRM_SUBMIT_ACTION_ID, - FF_TIMEBOX_UNDO_SUBMIT_ACTION_ID, - TimeboxingSubmitCoordinator, - TimeboxSubmitActionPayload, -) from .focus import FocusManager from .session_surface import ( @@ -289,44 +255,6 @@ def _timeboxing_excerpt_from_text(text: str) -> str: return cleaned -def _build_timeboxing_thread_root_blocks( - *, - title: str, - state: str, - constraints: list[Constraint], - thread_ts: str, - user_id: str, -) -> list[dict[str, object]]: - """Build the thread-root blocks with active constraints.""" - blocks: list[dict[str, object]] = [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": _timeboxing_thread_root_text( - title=title, request_excerpt=None, state=state - ), - }, - } - ] - active = [c for c in constraints if c.status != ConstraintStatus.DECLINED] - if active: - blocks.append({"type": "divider"}) - blocks.extend( - build_constraint_row_blocks( - active, thread_ts=thread_ts, user_id=user_id, limit=20 - ) - ) - else: - blocks.append( - { - "type": "context", - "elements": [{"type": "mrkdwn", "text": "No active constraints yet."}], - } - ) - return blocks - - def _extract_thread_state(result) -> str | None: for obj in (result, getattr(result, "chat_message", None)): state = getattr(obj, "thread_state", None) @@ -335,53 +263,6 @@ def _extract_thread_state(result) -> str | None: return None -async def _maybe_update_timeboxing_thread_constraints( - *, - client: AsyncWebClient, - focus: FocusManager, - thread_key: str, - user_id: str, - store: ConstraintStore | None, -) -> None: - """Update the timeboxing thread root with the latest active constraints.""" - if not store: - return - try: - channel_id, thread_root_ts = thread_key.split(":", 1) - except Exception: - return - if thread_root_ts == "dm": - return - label = focus.get_thread_label(thread_key) - if not label: - return - constraints = await store.list_constraints( - user_id=user_id, - channel_id=channel_id, - thread_ts=thread_root_ts, - ) - blocks = _build_timeboxing_thread_root_blocks( - title=label.title, - state=label.state, - constraints=constraints, - thread_ts=thread_root_ts, - user_id=user_id, - ) - try: - await client.chat_update( - channel=channel_id, - ts=thread_root_ts, - text=_timeboxing_thread_root_text( - title=label.title, - request_excerpt=label.request_excerpt, - state=label.state, - ), - blocks=blocks, - ) - except Exception: - return - - async def _maybe_update_timeboxing_thread_header( *, client: AsyncWebClient, @@ -531,36 +412,6 @@ def _extract_handoff_target(chat_message) -> str | None: ) -def _build_timeboxing_message( - *, - cleaned_text: str, - user: str, - channel: str, - thread_ts: str | None, - ts: str, - force_channel: str | None = None, - force_thread_root: str | None = None, - force_reply: bool | None = None, -) -> StartTimeboxing | TimeboxingUserReply: - resolved_channel = force_channel or channel - resolved_thread_root = force_thread_root or (thread_ts or ts) - is_reply = force_reply if force_reply is not None else bool(thread_ts) - - if is_reply: - return TimeboxingUserReply( - thread_ts=resolved_thread_root, - channel_id=resolved_channel, - user_id=user, - text=cleaned_text, - ) - return StartTimeboxing( - thread_ts=resolved_thread_root, - channel_id=resolved_channel, - user_id=user, - user_input=cleaned_text, - ) - - def _build_agent_message( *, agent_type: str, @@ -573,17 +424,6 @@ def _build_agent_message( force_thread_root: str | None = None, force_reply: bool | None = None, ) -> object: - if agent_type == "timeboxing_agent": - return _build_timeboxing_message( - cleaned_text=cleaned_text, - user=user, - channel=channel, - thread_ts=thread_ts, - ts=ts, - force_channel=force_channel, - force_thread_root=force_thread_root, - force_reply=force_reply, - ) return TextMessage(content=cleaned_text, source=user) @@ -892,92 +732,6 @@ def _plan_sessions_channel_id() -> str | None: return None -async def _harness_turn( - *, - text: str, - thread_key: str, - owner_user_id: str, - on_phase, - session_id: str | None = None, - history: list[tuple[str, str]] | None = None, - proposed_timebox: str | None = None, - proposed_calendar_id: str | None = None, - proposed_day: str | None = None, -) -> TextMessage: - """One Slack turn through the harness, shaped like a runtime reply. - - Returned as a TextMessage so every renderer downstream -- personas, block - compaction, thread updates -- keeps working untouched. The migration - changes which system thinks, not how the answer reaches Slack. - - The harness call is a blocking subprocess and a planning turn runs for tens - of seconds, so it goes to a worker thread; leaving it on the loop would - stall every other Slack event in the workspace. - """ - from .harness_bridge import PLANNING_MODEL, HarnessError - from .thread_approval import approval_path, revoke - - # Prefer the exact current process-owned rendering. Slack thread recovery - # supplies the same baseline after a restart, when this store is empty. - previous_candidate = _pending_candidates.peek(thread_key) - if previous_candidate is not None and previous_candidate.rendered.strip(): - proposed_timebox = previous_candidate.rendered - raw_calendar_id = previous_candidate.snapshot.get("calendar_id") - raw_day = previous_candidate.snapshot.get("day") - proposed_calendar_id = ( - raw_calendar_id if isinstance(raw_calendar_id, str) else None - ) - proposed_day = raw_day if isinstance(raw_day, str) else None - - # Any material new request invalidates the approval card it supersedes. - _pending_candidates.invalidate(thread_key) - revoke(thread_key) - - try: - reply = await _owned_harness_ask( - text, - thread_key=thread_key, - on_event=on_phase, - approval_file=str(approval_path(thread_key)), - # Without this the harness starts every turn with no idea the - # thread has a past. `thread_key` was already threaded here for the - # approval file; the conversation's own identity was not. - session_id=session_id, - history=history, - proposed_timebox=proposed_timebox, - proposed_calendar_id=proposed_calendar_id, - proposed_day=proposed_day, - # Every turn that reaches here is a planning turn: this function is - # the timeboxing path. The receptionist and the fast conversational - # replies do not come through it. - model=PLANNING_MODEL, - ) - except HarnessError as exc: - # Surfaced, not swallowed. A harness that could not be reached and a - # planner that declined to act must not read the same in the thread. - return TextMessage( - content=(f":warning: The harness did not answer.\n```{exc}```"), - source="timeboxing_agent", - ) - if reply.validated_candidate is not None: - # A clean tmbx candidate is approvable whether or not the model tried - # plan_commit. In particular, obeying "do not commit" must still show - # the one control that can later submit this exact displayed payload. - _pending_candidates.replace( - thread_key, reply.validated_candidate, owner_user_id=owner_user_id - ) - return TextMessage(content=reply.text, source="timeboxing_agent") - - -@dataclass -class _HarnessTurnControl: - cancel_event: threading.Event - on_phase: Callable[[object], None] - finished: asyncio.Event - - -_harness_turn_controls: dict[str, _HarnessTurnControl] = {} -_harness_turn_handoffs: dict[str, asyncio.Lock] = {} _thread_commit_locks: dict[str, asyncio.Lock] = {} _approval_tasks: set[asyncio.Task[None]] = set() @@ -990,66 +744,6 @@ def _thread_lock(registry: dict[str, asyncio.Lock], thread_key: str) -> asyncio. return lock -async def _owned_harness_ask( - text: str, - *, - thread_key: str, - on_event: Callable[[object], None], - **ask_kwargs, -): - """Run one cancellable child, superseding any older turn in the thread.""" - from .harness_bridge import HarnessCancelled, ask - - async with _thread_lock(_harness_turn_handoffs, thread_key): - previous = _harness_turn_controls.get(thread_key) - if previous is not None: - try: - previous.on_phase( - TimeboxProgressEvent( - session_key=thread_key, - sequence=0, - source=ProgressSource.RUNTIME, - phase=TimeboxProgressPhase.OTHER, - status=TimeboxProgressStatus.SUPERSEDED, - ) - ) - except Exception: - pass - previous.cancel_event.set() - await previous.finished.wait() - async with _thread_lock(_thread_commit_locks, thread_key): - control = _HarnessTurnControl( - cancel_event=threading.Event(), - on_phase=on_event, - finished=asyncio.Event(), - ) - _harness_turn_controls[thread_key] = control - worker = asyncio.create_task( - asyncio.to_thread( - ask, - text, - on_event=on_event, - cancel_event=control.cancel_event, - **ask_kwargs, - ) - ) - try: - return await asyncio.shield(worker) - except asyncio.CancelledError: - control.cancel_event.set() - try: - await worker - except HarnessCancelled: - pass - raise - except HarnessCancelled as exc: - raise asyncio.CancelledError from exc - finally: - control.finished.set() - if _harness_turn_controls.get(thread_key) is control: - _harness_turn_controls.pop(thread_key, None) - - def _note_harness_phase( card: HarnessProgressCard, event, @@ -1419,11 +1113,6 @@ def _timebox_start_button_value(blocks) -> str: return "" -def _timebox_backend() -> str: - """Which system answers /timebox. "harness" unless told otherwise.""" - return (os.environ.get("FF_TIMEBOX_BACKEND") or "harness").strip().lower() - - def _timebox_body_for_harness(body: dict) -> dict: """Give a bare /timebox something to plan. @@ -1754,11 +1443,8 @@ async def _run_adaptive_timebox_turn( tz_name=intent.planning_day.timezone, ) title = f"Timeboxing session for {label}" - # The message route redraws the root from the focus label at the end - # of every turn (`_maybe_update_timeboxing_thread_constraints`), so a - # relabel that only wrote Slack text was overwritten with the day - # the session *opened* on, milliseconds later. The label is the - # source; the write below is the same text, drawn now. + # The label is the source for the thread-root text; the write below + # draws it now, over the day the session *opened* on. if focus is not None: focus.set_thread_label( session_key, @@ -1981,7 +1667,7 @@ async def _handle_timebox_candidate_approval( `AwaitingApproval` with a calendar that disagreed with it. Returns False when this thread has no planning session to tell, which is - the legacy route's answer and its cue to commit the way it always has. + the caller's cue to write the candidate directly instead. """ repository = getattr(runtime, "timeboxing_session_store", None) if approval.expected_revision is None or repository is None: @@ -2362,7 +2048,6 @@ async def _route_command_as_message( body: dict, text: str, client: AsyncWebClient, - get_constraint_store: Callable[[], Awaitable[ConstraintStore | None]], ) -> None: """Drive a slash command through the route a typed message already takes. @@ -2397,7 +2082,6 @@ async def _noop_say(**_kwargs): bot_user_id=None, say=_noop_say, client=client, - get_constraint_store=get_constraint_store, ) @@ -2409,7 +2093,6 @@ async def _handle_timebox_command( body: dict, client: AsyncWebClient, respond: Callable | None, - get_constraint_store: Callable[[], Awaitable[ConstraintStore | None]], ) -> None: user_id = body.get("user_id") or "" channel_id = body.get("channel_id") or "" @@ -2437,7 +2120,6 @@ async def _handle_timebox_command( body=body, text=text, client=client, - get_constraint_store=get_constraint_store, ) except Exception as e: logger.exception("Timeboxing command route_slack_event failed") @@ -2455,7 +2137,6 @@ async def _handle_task_refine_command( body: dict, client: AsyncWebClient, respond: Callable | None, - get_constraint_store: Callable[[], Awaitable[ConstraintStore | None]], ) -> None: user_id = body.get("user_id") or "" channel_id = body.get("channel_id") or "" @@ -2483,7 +2164,6 @@ async def _handle_task_refine_command( body=body, text=text or "start guided task refinement session", client=client, - get_constraint_store=get_constraint_store, ) except Exception as e: logger.exception("Task refinement command route_slack_event failed") @@ -2573,7 +2253,6 @@ async def route_slack_event( bot_user_id: str | None, say: Callable, client: AsyncWebClient, - get_constraint_store: Callable[[], Awaitable[ConstraintStore | None]] | None = None, planning: PlanningCoordinator | None = None, acked: dict | None = None, ) -> None: @@ -2585,30 +2264,6 @@ async def route_slack_event( channel_type = event.get("channel_type") is_dm = channel_type == "im" or str(channel).startswith("D") - async def _update_constraints(thread_key: str) -> None: - """Refresh timeboxing constraints in the thread root message.""" - if not get_constraint_store: - return - try: - store = await get_constraint_store() - await _maybe_update_timeboxing_thread_constraints( - client=client, - focus=focus, - thread_key=thread_key, - user_id=user, - store=store, - ) - except Exception as exc: - record_error( - component="slack_routing", error_type="constraint_refresh_error" - ) - logger.warning( - "Non-fatal constraint refresh failure thread_key=%s user=%s error=%s", - thread_key, - user, - f"{type(exc).__name__}: {_safe_exc_summary(exc)}", - ) - # Give the conversation a memory. Fired without awaiting: `observe` costs a # model round trip and this route has a 30s budget, and the task reports # its own failure into the thread rather than into a log line -- a judge @@ -2868,39 +2523,20 @@ async def _begin_timeboxing_session_surface( processing = await client.chat_postMessage(**processing_payload) try: - if _timebox_backend() != "legacy": - result = await _run_adaptive_timebox_turn( - runtime=runtime, - client=client, - logger=logger, - session_key=redirect.target_key, - actor_user_id=user, - interaction_id=ts, - progress_channel=processing["channel"], - progress_ts=processing["ts"], - card_channel=target_channel, - card_thread_ts=root_ts, - user_text=cleaned_text, - focus=focus, - ) - else: - handoff_msg = _build_agent_message( - agent_type="timeboxing_agent", - cleaned_text=cleaned_text, - user=user, - channel=target_channel, - thread_ts=root_ts, - ts=root_ts, - force_channel=target_channel, - force_thread_root=root_ts, - force_reply=False, - ) - result = await runtime.send_message( - handoff_msg, - recipient=AgentId( - "timeboxing_agent", key=redirect.target_key - ), - ) + result = await _run_adaptive_timebox_turn( + runtime=runtime, + client=client, + logger=logger, + session_key=redirect.target_key, + actor_user_id=user, + interaction_id=ts, + progress_channel=processing["channel"], + progress_ts=processing["ts"], + card_channel=target_channel, + card_thread_ts=root_ts, + user_text=cleaned_text, + focus=focus, + ) except asyncio.TimeoutError: await client.chat_update( channel=target_channel, @@ -3008,7 +2644,6 @@ async def _begin_timeboxing_session_surface( thread_key=redirect.target_key, state=_extract_thread_state(result) or "", ) - await _update_constraints(redirect.target_key) except Exception: logger.exception( "timeboxing session surface failed after the root was posted " @@ -3093,48 +2728,67 @@ async def _begin_timeboxing_session_surface( processing_payload.update(_persona_payload(persona)) processing = await client.chat_postMessage(**processing_payload) - msg = _build_agent_message( - agent_type=redirect.agent_type, - cleaned_text=cleaned_text, - user=user, - channel=redirect.target_channel, - thread_ts=redirect.target_thread_ts, - ts=redirect.target_thread_ts, - force_channel=redirect.target_channel, - force_thread_root=redirect.target_thread_ts, - force_reply=True, - ) - try: - result = await runtime.send_message( - msg, recipient=AgentId(redirect.agent_type, key=redirect.target_key) - ) - except asyncio.TimeoutError: - record_error(component="slack_routing", error_type="stage_compute_failure") - await client.chat_update( - channel=redirect.target_channel, - ts=processing["ts"], - text=":hourglass_flowing_sand: Timed out waiting for tools/LLM. Please try again.", - ) - await _origin_update( - text=":hourglass_flowing_sand: Timed out waiting for tools/LLM. Please try again." - ) - return - except Exception as e: - record_error(component="slack_routing", error_type="stage_compute_failure") - logger.exception( - "runtime.send_message failed (redirect agent=%s key=%s)", - redirect.agent_type, - redirect.target_key, + if redirect.agent_type == "timeboxing_agent": + # A redirected timeboxing thread is an open session: continue it on + # the kernel, keyed by the redirect's own thread. There is nothing + # registered under "timeboxing_agent" to send to. + result = await _run_adaptive_timebox_turn( + runtime=runtime, + client=client, + logger=logger, + session_key=redirect.target_key, + actor_user_id=user, + interaction_id=ts, + progress_channel=redirect.target_channel, + progress_ts=processing["ts"], + card_channel=redirect.target_channel, + card_thread_ts=redirect.target_thread_ts, + user_text=cleaned_text, + focus=focus, ) - await client.chat_update( + else: + msg = _build_agent_message( + agent_type=redirect.agent_type, + cleaned_text=cleaned_text, + user=user, channel=redirect.target_channel, - ts=processing["ts"], - text=":warning: Something went wrong while handling that request. Check bot logs.", - ) - await _origin_update( - text=f":warning: {type(e).__name__}: {_safe_exc_summary(e)}" + thread_ts=redirect.target_thread_ts, + ts=redirect.target_thread_ts, + force_channel=redirect.target_channel, + force_thread_root=redirect.target_thread_ts, + force_reply=True, ) - return + try: + result = await runtime.send_message( + msg, recipient=AgentId(redirect.agent_type, key=redirect.target_key) + ) + except asyncio.TimeoutError: + record_error(component="slack_routing", error_type="stage_compute_failure") + await client.chat_update( + channel=redirect.target_channel, + ts=processing["ts"], + text=":hourglass_flowing_sand: Timed out waiting for tools/LLM. Please try again.", + ) + await _origin_update( + text=":hourglass_flowing_sand: Timed out waiting for tools/LLM. Please try again." + ) + return + except Exception as e: + record_error(component="slack_routing", error_type="stage_compute_failure") + logger.exception( + "runtime.send_message failed (redirect agent=%s key=%s)", + redirect.agent_type, + redirect.target_key, + ) + await client.chat_update( + channel=redirect.target_channel, + ts=processing["ts"], + text=":warning: Something went wrong while handling that request. Check bot logs.", + ) + await _origin_update( + text=f":warning: {type(e).__name__}: {_safe_exc_summary(e)}" + ) + return payload = _compact_slack_payload(**_slack_payload_from_result(result)) update = { @@ -3151,8 +2805,6 @@ async def _begin_timeboxing_session_surface( thread_key=redirect.target_key, state=_extract_thread_state(result) or "", ) - if redirect.agent_type == "timeboxing_agent": - await _update_constraints(redirect.target_key) if not is_dm: await _origin_link_to_thread( channel_id=redirect.target_channel, @@ -3164,15 +2816,15 @@ async def _begin_timeboxing_session_surface( # The fresh channel start used to root the session at the origin ack and # then use that same message as progress card and outcome card -- the # aliased layout that let a root relabel erase the Stage-0 card - # (2026-08-31 22:57). The harness path now builds the one real surface; - # only the legacy backend still takes the fallback below. + # (2026-08-31 22:57). The session surface below builds the one real + # surface instead. would_alias_root = ( agent_type == "timeboxing_agent" and not is_dm and not thread_ts and not origin_thread_root_ts ) - if would_alias_root and _timebox_backend() != "legacy": + if would_alias_root: session_channel = _channel_for_agent("timeboxing_agent") or channel await _begin_timeboxing_session_surface( target_channel=session_channel, @@ -3249,9 +2901,7 @@ async def _turn_heartbeat() -> None: # reply still goes out through _origin_update. return - primary_harness_turn = ( - agent_type == "timeboxing_agent" and _timebox_backend() != "legacy" - ) + primary_harness_turn = agent_type == "timeboxing_agent" heartbeat_task = ( None if primary_harness_turn else asyncio.create_task(_turn_heartbeat()) ) @@ -3319,23 +2969,56 @@ async def _turn_heartbeat() -> None: except ValueError: handoff_target = None + if handoff_target == "timeboxing_agent": + # Every door into timeboxing opens the same session surface. When no + # channel is configured, or the user is already in it, the session + # lives where they are -- the origin "thinking..." ack is repurposed + # into the root rather than left beside a second one (same reasoning + # as the fresh-channel-start branch above). Never the fall-through + # send below: there is nothing registered under this name to + # receive it. + session_channel = _channel_for_agent("timeboxing_agent") or channel + if session_channel != channel: + try: + await _begin_timeboxing_session_surface( + target_channel=session_channel, + origin_key=origin_key, + existing_root=None, + ) + except Exception: + # `open_session_surface` posts the root before this helper's + # own try/except, so a channel the bot cannot post into (the + # ordinary cause) would otherwise propagate out of + # `route_slack_event` -- neither caller of this function + # catches anything but `asyncio.TimeoutError`. Never fall + # through to the retired runtime send below: open the + # session where the user already is instead. + logger.warning( + "timeboxing session surface failed in configured " + "channel=%s; opening it in the origin channel=%s instead", + session_channel, + channel, + exc_info=True, + ) + await _begin_timeboxing_session_surface( + target_channel=channel, + origin_key=origin_key, + existing_root=origin_processing_msg, + ) + else: + await _begin_timeboxing_session_surface( + target_channel=session_channel, + origin_key=origin_key, + existing_root=origin_processing_msg, + ) + return + if handoff_target: focus.set_user_focus(user, handoff_target) target_channel = _channel_for_agent(handoff_target) - # For timeboxing, always anchor the session in the dedicated channel thread (when configured), - # even if the user started in a DM. The DM becomes the control surface (buttons/modals), - # and the channel thread becomes the durable workspace/log. - should_redirect = bool(target_channel and target_channel != channel) and ( - (not is_dm) or handoff_target == "timeboxing_agent" - ) + should_redirect = bool(target_channel and target_channel != channel) and (not is_dm) if should_redirect: try: - if handoff_target == "timeboxing_agent": - await _begin_timeboxing_session_surface( - target_channel=target_channel, - origin_key=origin_key, - ) - return persona = _persona_for_agent(handoff_target) root_payload = { "channel": target_channel, @@ -3467,12 +3150,8 @@ async def _turn_heartbeat() -> None: channel=channel, thread_ts=thread_ts, ts=ts, - force_thread_root=( - "dm" if (is_dm and handoff_target == "timeboxing_agent") else None - ), - force_reply=( - True if (is_dm and handoff_target == "timeboxing_agent") else None - ), + force_thread_root=None, + force_reply=None, ) try: result = await runtime.send_message( @@ -3541,8 +3220,6 @@ async def _turn_heartbeat() -> None: thread_key=origin_key, state=_extract_thread_state(result) or "", ) - if agent_type == "timeboxing_agent": - await _update_constraints(origin_key) def register_handlers( @@ -3560,15 +3237,9 @@ def register_handlers( - App mention handler : route @mentions via focusβ†’agent - DM handler : route DMs via focusβ†’agent """ - constraint_store: ConstraintStore | None = None workspace_store: SlackWorkspaceStore | None = None planning = PlanningCoordinator(runtime=runtime, focus=focus, client=app.client) planning.attach_reconciler_dispatch() - timeboxing_commit = TimeboxingCommitCoordinator(runtime=runtime, client=app.client) - timeboxing_submit = TimeboxingSubmitCoordinator(runtime=runtime, client=app.client) - timeboxing_stage_actions = TimeboxingStageActionCoordinator( - runtime=runtime, client=app.client - ) workspace_bootstrap_attempted = False invited_users: set[str] = set() @@ -3616,18 +3287,6 @@ async def _ensure_workspace_registry(client: AsyncWebClient) -> None: except Exception: logger.debug("Failed to load workspace bindings from DB", exc_info=True) - async def _get_constraint_store() -> ConstraintStore | None: - nonlocal constraint_store - if constraint_store: - return constraint_store - if not settings.database_url: - return None - engine = create_async_engine(_coerce_async_database_url(settings.database_url)) - await ensure_constraint_schema(engine) - sessionmaker = async_sessionmaker(engine, expire_on_commit=False) - constraint_store = ConstraintStore(sessionmaker) - return constraint_store - async def _get_workspace_store() -> SlackWorkspaceStore | None: nonlocal workspace_store if workspace_store: @@ -3762,7 +3421,6 @@ async def _post_dispatch_fallback(text: str) -> None: bot_user_id=bot_user_id, say=say, client=client, - get_constraint_store=_get_constraint_store, planning=planning, ) ) @@ -4025,25 +3683,24 @@ async def act_harness_approve(ack, body, client, logger): logger.warning("approve candidate thread did not match message thread") return - if _timebox_backend() != "legacy": - # A planning session exists for this thread, so the commit belongs - # inside it: the kernel is what decides a commit is allowed and - # what stores the receipt afterwards. The write itself is the same - # one either way -- same candidate, same idempotency digest. - handled = await _handle_timebox_candidate_approval( - runtime=runtime, - client=client, - logger=logger, - approval=approval, - channel_id=channel, - thread_ts=thread_root, - actor_user_id=actor_user_id, - interaction_id=_card_interaction_id( - action, FF_HARNESS_APPROVE_ACTION_ID, thread_root - ), - ) - if handled: - return + # A planning session exists for this thread, so the commit belongs + # inside it: the kernel is what decides a commit is allowed and what + # stores the receipt afterwards. The write itself is the same one + # either way -- same candidate, same idempotency digest. + handled = await _handle_timebox_candidate_approval( + runtime=runtime, + client=client, + logger=logger, + approval=approval, + channel_id=channel, + thread_ts=thread_root, + actor_user_id=actor_user_id, + interaction_id=_card_interaction_id( + action, FF_HARNESS_APPROVE_ACTION_ID, thread_root + ), + ) + if handled: + return task = asyncio.create_task( _execute_harness_approval( @@ -4078,17 +3735,12 @@ async def cmd_dsh(ack, body, client, logger): @app.command("/timebox") async def cmd_timebox(ack, body, respond, client, logger): - """Plan a day. Both backends start the same way: by asking which day. - - Neither backend launches a planner here any more. `/timebox` creates or - reuses the plan-session thread and renders the date card; the harness - backend then continues through the adaptive session kernel and the - legacy backend through the five-stage machine. Forking a second thread - creation for the harness is what once gave the two backends different - session identities for the same conversation. + """Plan a day, which starts by asking which day. - FF_TIMEBOX_BACKEND=legacy routes back to the AutoGen flow, which stays - wired and reachable. A migration nobody can reverse is a rewrite. + No planner is launched here. `/timebox` creates or reuses the + plan-session thread and renders the date card; the adaptive session + kernel continues from there. Forking a second thread creation for the + kernel is what once gave one conversation two session identities. """ await ack() # Fire off in background to avoid blocking Slack's 3-second timeout @@ -4100,7 +3752,6 @@ async def cmd_timebox(ack, body, respond, client, logger): body=body, client=client, respond=respond, - get_constraint_store=_get_constraint_store, ) ) @@ -4114,7 +3765,6 @@ async def cmd_task_refine(ack, body, respond, client, logger): body=body, client=client, respond=respond, - get_constraint_store=_get_constraint_store, ) ) @@ -4471,12 +4121,7 @@ async def on_task_edit_modal_submit(ack, body, client, logger): @app.action(FF_TIMEBOX_COMMIT_START_ACTION_ID) async def on_timebox_commit_start_action(ack, body, client, logger): - """Confirm the planning day, on whichever backend owns the session. - - The two backends share this one control because they share the card. - Which one answers is the same decision `/timebox` already made, read - again here rather than remembered in the button. - """ + """Confirm the planning day and start the kernel session.""" await ack() channel_id = (body.get("channel") or {}).get("id") or "" message_ts = (body.get("message") or {}).get("ts") or "" @@ -4485,14 +4130,6 @@ async def on_timebox_commit_start_action(ack, body, client, logger): value = action.get("value") or "" if not (channel_id and message_ts and value): return - if _timebox_backend() == "legacy": - await timeboxing_commit.handle_start_action( - value=value, - prompt_channel_id=channel_id, - prompt_ts=message_ts, - actor_user_id=actor_user_id, - ) - return await _handle_timebox_date_confirmation( runtime=runtime, client=client, @@ -4605,14 +4242,6 @@ async def on_timebox_commit_day_select_action(ack, body, client, logger): if not (channel_id and message_ts and selected_date and meta_value): return - if _timebox_backend() == "legacy": - await timeboxing_commit.handle_day_select_action( - prompt_channel_id=channel_id, - prompt_ts=message_ts, - selected_date=selected_date, - existing_meta_value=meta_value, - ) - return await _handle_timebox_date_reselect( client=client, logger=logger, @@ -4622,204 +4251,12 @@ async def on_timebox_commit_day_select_action(ack, body, client, logger): prompt_ts=message_ts, ) - @app.action(FF_TIMEBOX_CONFIRM_SUBMIT_ACTION_ID) - async def on_timebox_confirm_submit_action(ack, body, client, logger): - """Handle Stage 5 confirm-submit button clicks.""" - await ack() - payload = TimeboxSubmitActionPayload.from_action_body(body) - if not payload: - return - await timeboxing_submit.handle_confirm_action(payload=payload) - - @app.action(FF_TIMEBOX_CANCEL_SUBMIT_ACTION_ID) - async def on_timebox_cancel_submit_action(ack, body, client, logger): - """Handle Stage 5 cancel-submit button clicks.""" - await ack() - payload = TimeboxSubmitActionPayload.from_action_body(body) - if not payload: - return - await timeboxing_submit.handle_cancel_action(payload=payload) - - @app.action(FF_TIMEBOX_UNDO_SUBMIT_ACTION_ID) - async def on_timebox_undo_submit_action(ack, body, client, logger): - """Handle Stage 5 undo-submit button clicks.""" - await ack() - payload = TimeboxSubmitActionPayload.from_action_body(body) - if not payload: - return - await timeboxing_submit.handle_undo_action(payload=payload) - - @app.action(FF_TIMEBOX_STAGE_PROCEED_ACTION_ID) - async def on_timebox_stage_proceed_action(ack, body, client, logger): - """Handle deterministic stage proceed button clicks.""" - await ack() - payload = TimeboxingStageActionPayload.from_action_body(body) - if not payload: - return - await timeboxing_stage_actions.handle_action( - payload=payload, - action="proceed", - ) - - @app.action(FF_TIMEBOX_STAGE_BACK_ACTION_ID) - async def on_timebox_stage_back_action(ack, body, client, logger): - """Handle deterministic stage back button clicks.""" - await ack() - payload = TimeboxingStageActionPayload.from_action_body(body) - if not payload: - return - await timeboxing_stage_actions.handle_action( - payload=payload, - action="back", - ) - - @app.action(FF_TIMEBOX_STAGE_REDO_ACTION_ID) - async def on_timebox_stage_redo_action(ack, body, client, logger): - """Handle deterministic stage redo button clicks.""" - await ack() - payload = TimeboxingStageActionPayload.from_action_body(body) - if not payload: - return - await timeboxing_stage_actions.handle_action( - payload=payload, - action="redo", - ) - - @app.action(FF_TIMEBOX_STAGE_CANCEL_ACTION_ID) - async def on_timebox_stage_cancel_action(ack, body, client, logger): - """Handle deterministic stage cancel button clicks.""" - await ack() - payload = TimeboxingStageActionPayload.from_action_body(body) - if not payload: - return - await timeboxing_stage_actions.handle_action( - payload=payload, - action="cancel", - ) - - async def _handle_constraint_review_all_action(body, client): - action = (body.get("actions") or [{}])[0] - value = action.get("value") or "" - metadata = decode_metadata(value) - thread_ts = ( - metadata.get("thread_ts") - or (body.get("message") or {}).get("thread_ts") - or (body.get("message") or {}).get("ts") - or "" - ) - user_id = metadata.get("user_id") or (body.get("user") or {}).get("id") or "" - channel_id = ( - body.get("channel", {}).get("id") or metadata.get("channel_id") or "" - ) - trigger_id = body.get("trigger_id") or "" - if not (thread_ts and user_id and channel_id and trigger_id): - return - - store = await _get_constraint_store() - if not store: - return - constraints = await store.list_constraints( - user_id=user_id, - channel_id=channel_id, - thread_ts=thread_ts, - ) - active_constraints = [ - constraint - for constraint in constraints - if constraint.status != ConstraintStatus.DECLINED - ] - view = build_constraint_review_list_view( - active_constraints, - channel_id=channel_id, - thread_ts=thread_ts, - user_id=user_id, - ) - await client.views_open(trigger_id=trigger_id, view=view) - - @app.action(FF_CONSTRAINT_REVIEW_ALL_ACTION_ID) - async def on_constraint_review_all_action(ack, body, client, logger): + async def _on_retired_card(ack, body, client, logger): await ack() - await _handle_constraint_review_all_action(body, client) + await retired_cards.retire_card(client=client, body=body) - @app.action(LEGACY_CONSTRAINT_REVIEW_ALL_ACTION_ID) - async def on_constraint_review_all_action_legacy(ack, body, client, logger): - await ack() - await _handle_constraint_review_all_action(body, client) - - @app.action(CONSTRAINT_ROW_REVIEW_ACTION_ID) - async def on_constraint_review_action(ack, body, client, logger): - await ack() - action = (body.get("actions") or [{}])[0] - value = action.get("value") or "" - metadata = decode_metadata(value) - constraint_id_raw = metadata.get("constraint_id") or "" - thread_ts = metadata.get("thread_ts") or "" - user_id = metadata.get("user_id") or "" - channel_id = body.get("channel", {}).get("id") or "" - if not (constraint_id_raw and user_id and channel_id): - return - try: - constraint_id = int(constraint_id_raw) - except ValueError: - return - - store = await _get_constraint_store() - if not store: - return - constraint = await store.get_constraint( - user_id=user_id, constraint_id=constraint_id - ) - if not constraint: - return - if thread_ts and constraint.thread_ts and constraint.thread_ts != thread_ts: - return - view = build_constraint_review_view( - constraint, - channel_id=channel_id, - thread_ts=thread_ts or (constraint.thread_ts or ""), - user_id=user_id, - ) - await client.views_open(trigger_id=body["trigger_id"], view=view) - - @app.view(CONSTRAINT_REVIEW_VIEW_CALLBACK_ID) - async def on_constraint_review_submit(ack, body, client, logger): - await ack() - store = await _get_constraint_store() - if not store: - return - state = body.get("view", {}).get("state", {}).get("values", {}) - status, description = parse_constraint_review_submission(state) - metadata = body.get("view", {}).get("private_metadata") or "" - info = decode_metadata(metadata) - constraint_id_raw = info.get("constraint_id") or "" - user_id = info.get("user_id") or body.get("user", {}).get("id", "") or "" - channel_id = info.get("channel_id") or "" - thread_ts = info.get("thread_ts") or "" - if not (constraint_id_raw and user_id): - return - try: - constraint_id = int(constraint_id_raw) - except ValueError: - return - await store.update_constraint( - user_id=user_id, - constraint_id=constraint_id, - status=status, - description=description, - ) - if channel_id and thread_ts: - await client.chat_postMessage( - channel=channel_id, - thread_ts=thread_ts, - text="Saved your constraint update.", - ) - await _maybe_update_timeboxing_thread_constraints( - client=client, - focus=focus, - thread_key=f"{channel_id}:{thread_ts}", - user_id=user_id, - store=store, - ) + for _retired_id in retired_cards.RETIRED_ACTION_IDS: + app.action(_retired_id)(_on_retired_card) # --- App Home (Command Center) --- @app.event("app_home_opened") diff --git a/src/fateforger/slack_bot/relay_agent.py b/src/fateforger/slack_bot/relay_agent.py deleted file mode 100644 index c8224139..00000000 --- a/src/fateforger/slack_bot/relay_agent.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Agent that relays topic outputs into the Slack bridge queues.""" - -from __future__ import annotations - -import logging -from typing import Any - -from autogen_agentchat.messages import BaseChatMessage, TextMessage -from autogen_core import MessageContext, RoutedAgent, message_handler - -from .topics import TopicRegistry -from fateforger.agents.timeboxing.messages import TimeboxingFinalResult - - -logger = logging.getLogger(__name__) - - -class SlackRelayAgent(RoutedAgent): - """Collects messages published to Slack thread topics and enqueues them for Bolt.""" - - def __init__(self, name: str = "slack_bridge") -> None: - super().__init__(description=name) - - def _enqueue(self, ctx: MessageContext, payload: dict[str, Any]) -> None: - registry = TopicRegistry.get_global() - if not registry: - logger.debug("No TopicRegistry available; dropping payload") - return - if not ctx.topic_id: - logger.debug("No topic id in message context; dropping payload") - return - binding = registry.get_by_topic(ctx.topic_id) - if not binding: - logger.debug("No binding for topic %s; payload ignored", ctx.topic_id) - return - binding.queue.put_nowait(payload) - - @message_handler - async def handle_text(self, message: TextMessage, ctx: MessageContext) -> None: - self._enqueue( - ctx, - { - "type": "text", - "content": message.content, - "source": message.source, - }, - ) - - @message_handler - async def handle_final(self, message: TimeboxingFinalResult, ctx: MessageContext) -> None: - self._enqueue( - ctx, - { - "type": "final", - "status": message.status, - "summary": message.summary, - "payload": message.payload, - }, - ) - - -__all__ = ["SlackRelayAgent"] - diff --git a/src/fateforger/slack_bot/retired_cards.py b/src/fateforger/slack_bot/retired_cards.py new file mode 100644 index 00000000..bfd38f43 --- /dev/null +++ b/src/fateforger/slack_bot/retired_cards.py @@ -0,0 +1,77 @@ +"""One answer for every button the legacy timeboxing agent left in Slack. + +The ten action ids below were posted by that agent's stage and submit +cards, plus the constraint-review row/all buttons that +`constraint_review.py` (deleted) rendered on the memory-tool-result blocks +posted to legacy timeboxing session thread roots. The agent is gone; a +press must say so, not fail. The ids are kept verbatim because Slack will +keep sending them for as long as the messages exist. +""" + +from __future__ import annotations + +from typing import Any + +FF_TIMEBOX_CONFIRM_SUBMIT_ACTION_ID = "ff_timebox_confirm_submit" +FF_TIMEBOX_CANCEL_SUBMIT_ACTION_ID = "ff_timebox_cancel_submit" +FF_TIMEBOX_UNDO_SUBMIT_ACTION_ID = "ff_timebox_undo_submit" +FF_TIMEBOX_STAGE_PROCEED_ACTION_ID = "ff_timebox_stage_proceed" +FF_TIMEBOX_STAGE_BACK_ACTION_ID = "ff_timebox_stage_back" +FF_TIMEBOX_STAGE_REDO_ACTION_ID = "ff_timebox_stage_redo" +FF_TIMEBOX_STAGE_CANCEL_ACTION_ID = "ff_timebox_stage_cancel" + +# Rendered by constraint_review.py (deleted at 6f93212) on the +# memory-tool-result blocks it built for legacy timeboxing session thread +# roots: a per-row "Review" accessory button and a "Review all constraints" +# actions-block button. Their four Bolt listeners (two of them sharing one +# handler for the current and legacy "review all" ids) were deleted with +# that module; the view callback they opened is not listed here because a +# modal is unreachable once the button that opens it is inert. +CONSTRAINT_ROW_REVIEW_ACTION_ID = "timeboxing_constraint_review" +FF_CONSTRAINT_REVIEW_ALL_ACTION_ID = "ff_timeboxing_constraint_review_all" +LEGACY_CONSTRAINT_REVIEW_ALL_ACTION_ID = "timeboxing_constraint_review_all" + +RETIRED_ACTION_IDS: tuple[str, ...] = ( + FF_TIMEBOX_CONFIRM_SUBMIT_ACTION_ID, + FF_TIMEBOX_CANCEL_SUBMIT_ACTION_ID, + FF_TIMEBOX_UNDO_SUBMIT_ACTION_ID, + FF_TIMEBOX_STAGE_PROCEED_ACTION_ID, + FF_TIMEBOX_STAGE_BACK_ACTION_ID, + FF_TIMEBOX_STAGE_REDO_ACTION_ID, + FF_TIMEBOX_STAGE_CANCEL_ACTION_ID, + CONSTRAINT_ROW_REVIEW_ACTION_ID, + FF_CONSTRAINT_REVIEW_ALL_ACTION_ID, + LEGACY_CONSTRAINT_REVIEW_ALL_ACTION_ID, +) + +RETIRED_CARD_TEXT = ( + "This card is from a retired planning flow and its buttons no longer do " + "anything. Start again with /timebox." +) + + +def _text_section_block(*, text: str) -> dict[str, Any]: + """Render markdown text content as a Slack section block. + + A private copy rather than an import from `slack_bot.messages`: this + module is the one thing here that does not die with the legacy agent, so + it does not share plumbing with the modules that do. + """ + return { + "type": "section", + "text": {"type": "mrkdwn", "text": text or "(no response)"}, + } + + +async def retire_card(*, client, body: dict) -> None: + """Rewrite the pressed message in place; a press with no message is a no-op.""" + channel_id = (body.get("channel") or {}).get("id") or "" + message_ts = (body.get("message") or {}).get("ts") or "" + if not (channel_id and message_ts): + return + await client.chat_update( + channel=channel_id, + ts=message_ts, + text=RETIRED_CARD_TEXT, + blocks=[_text_section_block(text=RETIRED_CARD_TEXT)], + ) diff --git a/src/fateforger/slack_bot/timeboxing_commit.py b/src/fateforger/slack_bot/timeboxing_commit.py index 53a47875..6fc28e10 100644 --- a/src/fateforger/slack_bot/timeboxing_commit.py +++ b/src/fateforger/slack_bot/timeboxing_commit.py @@ -4,19 +4,13 @@ from datetime import date, datetime, timedelta, timezone from typing import Any, Literal +from urllib.parse import parse_qs, urlencode from zoneinfo import ZoneInfo, ZoneInfoNotFoundError -from autogen_core import AgentId from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator -from slack_sdk.web.async_client import AsyncWebClient -from fateforger.agents.timeboxing.messages import TimeboxingCommitDate from fateforger.agents.timeboxing.session_contracts import DayType -from fateforger.slack_bot.constraint_review import decode_metadata, encode_metadata from fateforger.slack_bot.messages import SlackBlockMessage -from fateforger.slack_bot.reply_guard import agent_reply_text -from fateforger.slack_bot.ui import link_button -from fateforger.slack_bot.workspace import WorkspaceRegistry FF_TIMEBOX_COMMIT_START_ACTION_ID = "ff_timebox_start" FF_TIMEBOX_COMMIT_DAY_SELECT_ACTION_ID = "ff_timebox_day_select" @@ -40,22 +34,6 @@ def day_type_action_id(day_type: "DayType") -> str: return f"{FF_TIMEBOX_DAY_TYPE_ACTION_ID}_{day_type.value}" -def _persona_payload(agent_type: str) -> dict[str, Any]: - """Return Slack message persona overrides for a given agent type.""" - directory = WorkspaceRegistry.get_global() - persona = directory.persona_for_agent(agent_type) if directory else None - if not persona: - return {} - payload: dict[str, Any] = {} - if persona.username: - payload["username"] = persona.username - if persona.icon_emoji: - payload["icon_emoji"] = persona.icon_emoji - if persona.icon_url: - payload["icon_url"] = persona.icon_url - return payload - - def _iter_days(start: date, *, count: int) -> list[date]: """Return a list of consecutive calendar days starting at `start`.""" return [start + timedelta(days=offset) for offset in range(count)] @@ -163,6 +141,19 @@ def build_timebox_commit_prompt_message( ) +def encode_metadata(values: dict[str, str]) -> str: + """Encode modal metadata into a querystring value.""" + return urlencode(values) + + +def decode_metadata(payload: str) -> dict[str, str]: + """Decode modal metadata from a querystring payload.""" + if not payload: + return {} + parsed = parse_qs(payload, keep_blank_values=True) + return {key: value[0] for key, value in parsed.items()} + + class TimeboxCommitMeta(BaseModel): """Encoded metadata passed through Slack interactive payloads.""" @@ -359,208 +350,14 @@ def build_day_type_override_blocks(meta: TimeboxCommitMeta) -> list[dict[str, An ] -class TimeboxingCommitCoordinator: - def __init__(self, *, runtime, client: AsyncWebClient) -> None: - """Create the coordinator that bridges Slack actions to the timeboxing agent.""" - self._runtime = runtime - self._client = client - - async def handle_start_action( - self, - *, - value: str, - prompt_channel_id: str, - prompt_ts: str, - actor_user_id: str | None, - ) -> None: - """Handle the 'Confirm' button and dispatch `TimeboxingCommitDate` to the agent.""" - meta = TimeboxCommitMeta.from_value(value) - if not meta: - return - - planned_date = meta.date - tz_name = meta.tz or "UTC" - thread_key = f"{meta.channel_id}:{meta.thread_ts}" - - # Immediately update the prompt message to show loading state - display_day = format_relative_day_label( - planned_date=planned_date, tz_name=tz_name - ) - loading_blocks: list[dict[str, Any]] = [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"⏳ Starting timeboxing for *{display_day}*...", - }, - } - ] - try: - await self._client.chat_update( - channel=prompt_channel_id, - ts=prompt_ts, - text=f"Starting timeboxing for {display_day}...", - blocks=loading_blocks, - ) - except Exception: - pass - - processing_payload: dict[str, Any] = { - "channel": meta.channel_id, - "text": ":hourglass_flowing_sand: *timeboxing_agent* is thinking...", - **_persona_payload("timeboxing_agent"), - } - # Only include thread_ts if it's a real message timestamp (not "dm") - if meta.thread_ts and meta.thread_ts != "dm": - processing_payload["thread_ts"] = meta.thread_ts - processing = await self._client.chat_postMessage(**processing_payload) - - try: - result = await self._runtime.send_message( - TimeboxingCommitDate( - channel_id=meta.channel_id, - thread_ts=meta.thread_ts, - user_id=meta.user_id or (actor_user_id or ""), - planned_date=planned_date, - timezone=tz_name, - ), - recipient=AgentId("timeboxing_agent", key=thread_key), - ) - except Exception: - await self._client.chat_update( - channel=meta.channel_id, - ts=processing["ts"], - text=":warning: Something went wrong while starting timeboxing. Check bot logs.", - ) - return - - payload = _slack_payload_from_result(result) - update = { - "channel": meta.channel_id, - "ts": processing["ts"], - "text": payload.get("text", "") or "", - } - if payload.get("blocks"): - update["blocks"] = payload["blocks"] - await self._client.chat_update(**update) - - # Mark the session thread root as "in progress" once the user confirms. - # Skip if thread_ts is "dm" (not a real message) - display_day = format_relative_day_label( - planned_date=planned_date, tz_name=tz_name - ) - if meta.thread_ts and meta.thread_ts != "dm": - try: - await self._client.chat_update( - channel=meta.channel_id, - ts=meta.thread_ts, - text=f":large_blue_circle: Timeboxing session for {display_day}", - ) - except Exception: - pass - - # Update the prompt message (DM/channel) with a "Go to session" link for convenience. - # Only show the link if the session is in a different channel (redirect case). - link = "" - is_redirect = prompt_channel_id != meta.channel_id - if is_redirect and meta.thread_ts and meta.thread_ts != "dm": - try: - perma = await self._client.chat_getPermalink( - channel=meta.channel_id, message_ts=meta.thread_ts - ) - link = perma.get("permalink") or "" - except Exception: - pass - blocks: list[dict[str, Any]] = [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"Timeboxing for *{display_day}* started.", - }, - } - ] - if link: - blocks.append( - { - "type": "actions", - "elements": [ - link_button( - text="Go to session", - url=link, - action_id="ff_open_thread", - ) - ], - } - ) - try: - await self._client.chat_update( - channel=prompt_channel_id, - ts=prompt_ts, - text=f"Timeboxing for {display_day} started.", - blocks=blocks, - ) - except Exception: - pass - - async def handle_day_select_action( - self, - *, - prompt_channel_id: str, - prompt_ts: str, - selected_date: str, - existing_meta_value: str, - ) -> None: - meta = TimeboxCommitMeta.from_value(existing_meta_value) - if not meta: - return - try: - updated_meta = meta.with_selected_date(selected_date) - except (TypeError, ValueError, ValidationError): - return - value = updated_meta.to_value() - prompt = build_timebox_commit_prompt_message( - planned_date=selected_date, tz_name=meta.tz, meta_value=value - ) - # Keep the session thread title aligned with the currently selected day. - try: - label = format_relative_day_label( - planned_date=selected_date, tz_name=meta.tz - ) - await self._client.chat_update( - channel=meta.channel_id, - ts=meta.thread_ts, - text=f":large_yellow_circle: Timeboxing session for {label}", - ) - except Exception: - pass - await self._client.chat_update( - channel=prompt_channel_id, - ts=prompt_ts, - text=prompt.text, - blocks=prompt.blocks, - ) - - -def _slack_payload_from_result(result: Any) -> dict[str, Any]: - chat_message = getattr(result, "chat_message", None) or result - if hasattr(chat_message, "blocks") and hasattr(chat_message, "text"): - blocks = getattr(chat_message, "blocks", None) - text = getattr(chat_message, "text", None) - if blocks is not None: - return {"text": text or "", "blocks": blocks} - return {"text": text or ""} - # `chat_message` already collapsed to `result` above, so this is the whole non-Slack case. - return {"text": agent_reply_text(chat_message)} - - __all__ = [ "FF_TIMEBOX_COMMIT_START_ACTION_ID", "FF_TIMEBOX_COMMIT_DAY_SELECT_ACTION_ID", "FF_TIMEBOX_DAY_TYPE_ACTION_ID", "day_type_action_id", + "decode_metadata", + "encode_metadata", "TimeboxCommitMeta", - "TimeboxingCommitCoordinator", "build_day_type_override_blocks", "build_timebox_commit_prompt_message", "build_timebox_date_card", diff --git a/src/fateforger/slack_bot/timeboxing_stage_actions.py b/src/fateforger/slack_bot/timeboxing_stage_actions.py deleted file mode 100644 index 6792dd9b..00000000 --- a/src/fateforger/slack_bot/timeboxing_stage_actions.py +++ /dev/null @@ -1,283 +0,0 @@ -"""Slack stage-control actions for deterministic timeboxing progression.""" - -from __future__ import annotations - -from typing import Any, Literal - -from autogen_core import AgentId -from pydantic import BaseModel -from slack_sdk.web.async_client import AsyncWebClient - -from fateforger.agents.timeboxing.messages import TimeboxingStageAction -from fateforger.slack_bot.constraint_review import decode_metadata -from fateforger.slack_bot.reply_guard import agent_reply_text -from fateforger.slack_bot.timeboxing_submit import build_text_section_block - -FF_TIMEBOX_STAGE_PROCEED_ACTION_ID = "ff_timebox_stage_proceed" -FF_TIMEBOX_STAGE_BACK_ACTION_ID = "ff_timebox_stage_back" -FF_TIMEBOX_STAGE_REDO_ACTION_ID = "ff_timebox_stage_redo" -FF_TIMEBOX_STAGE_CANCEL_ACTION_ID = "ff_timebox_stage_cancel" - - -class TimeboxingStageActionMeta(BaseModel): - """Metadata encoded into stage-control button values.""" - - channel_id: str - thread_ts: str - user_id: str - - @classmethod - def from_value(cls, value: str) -> "TimeboxingStageActionMeta | None": - """Parse stage-control metadata from encoded button value.""" - raw = decode_metadata(value) - try: - return cls.model_validate( - { - "channel_id": raw.get("channel_id") or "", - "thread_ts": raw.get("thread_ts") or "", - "user_id": raw.get("user_id") or "", - } - ) - except Exception: - return None - - -class TimeboxingStageActionPayload(BaseModel): - """Normalized Slack stage-action callback payload.""" - - value: str - prompt_channel_id: str - prompt_ts: str - actor_user_id: str | None = None - - @classmethod - def from_action_body( - cls, body: dict[str, Any] - ) -> "TimeboxingStageActionPayload | None": - """Extract typed payload fields from a Slack action body.""" - actions = body.get("actions") or [] - action = actions[0] if isinstance(actions, list) and actions else {} - value = action.get("value") if isinstance(action, dict) else "" - channel_id = (body.get("channel") or {}).get("id") or "" - message_ts = (body.get("message") or {}).get("ts") or "" - actor_user_id = (body.get("user") or {}).get("id") - if not (value and channel_id and message_ts): - return None - try: - return cls.model_validate( - { - "value": str(value), - "prompt_channel_id": str(channel_id), - "prompt_ts": str(message_ts), - "actor_user_id": str(actor_user_id) if actor_user_id else None, - } - ) - except Exception: - return None - - -def build_stage_actions_block( - *, - meta_value: str, - can_proceed: bool, - can_go_back: bool, - redo_label: str = "Redo", - include_cancel: bool = True, -) -> dict[str, Any]: - """Build deterministic stage-control buttons for a timeboxing stage.""" - elements: list[dict[str, Any]] = [] - if can_proceed: - elements.append( - { - "type": "button", - "action_id": FF_TIMEBOX_STAGE_PROCEED_ACTION_ID, - "text": {"type": "plain_text", "text": "Proceed"}, - "style": "primary", - "value": meta_value, - } - ) - if can_go_back: - elements.append( - { - "type": "button", - "action_id": FF_TIMEBOX_STAGE_BACK_ACTION_ID, - "text": {"type": "plain_text", "text": "Back"}, - "value": meta_value, - } - ) - elements.append( - { - "type": "button", - "action_id": FF_TIMEBOX_STAGE_REDO_ACTION_ID, - "text": {"type": "plain_text", "text": redo_label}, - "value": meta_value, - } - ) - if include_cancel: - elements.append( - { - "type": "button", - "action_id": FF_TIMEBOX_STAGE_CANCEL_ACTION_ID, - "text": {"type": "plain_text", "text": "Cancel"}, - "style": "danger", - "value": meta_value, - } - ) - return { - "type": "actions", - "block_id": "ff_timebox_stage_actions", - "elements": elements, - } - - -class TimeboxingStageActionCoordinator: - """Bridge stage-control Slack actions to typed runtime messages.""" - - def __init__(self, *, runtime: Any, client: AsyncWebClient) -> None: - """Initialize coordinator dependencies.""" - self._runtime = runtime - self._client = client - - async def handle_action( - self, - *, - payload: TimeboxingStageActionPayload, - action: Literal["proceed", "back", "redo", "cancel"], - ) -> None: - """Handle a deterministic stage action and replace the prompt message.""" - meta = TimeboxingStageActionMeta.from_value(payload.value) - if not meta: - return - in_progress_text = _stage_action_in_progress_text(action) - await self._client.chat_update( - channel=payload.prompt_channel_id, - ts=payload.prompt_ts, - text=in_progress_text, - blocks=[build_text_section_block(text=in_progress_text)], - ) - msg = TimeboxingStageAction( - channel_id=meta.channel_id, - thread_ts=meta.thread_ts, - user_id=meta.user_id or (payload.actor_user_id or ""), - action=action, - ) - await self._dispatch_to_timeboxing( - payload=payload, - meta=meta, - message=msg, - failure_text="Stage action failed. Please try again.", - action=action, - ) - - async def _dispatch_to_timeboxing( - self, - *, - payload: TimeboxingStageActionPayload, - meta: TimeboxingStageActionMeta, - message: TimeboxingStageAction, - failure_text: str, - action: str = "stage action", - ) -> None: - """Send stage-control message to runtime and update Slack in-place.""" - thread_key = f"{meta.channel_id}:{meta.thread_ts}" - try: - result = await self._runtime.send_message( - message, - recipient=AgentId("timeboxing_agent", key=thread_key), - ) - except Exception: - await self._client.chat_update( - channel=payload.prompt_channel_id, - ts=payload.prompt_ts, - text=failure_text, - blocks=[build_text_section_block(text=f":warning: {failure_text}")], - ) - return - response_payload = _slack_payload_from_result(result) - - # Post the stage result as a NEW message rather than rewriting the one - # the button sits on. That message accumulated the whole stage -- day - # overview, constraint list, expanded bodies, buttons -- and every - # subsequent stage rewrote the lot, so it grew until chat.update - # returned msg_too_long and the session went silent with no channel - # left to say why. The repair is not fewer edits but never re-editing - # the part that accumulates: artifacts need to *arrive*, not to be - # rewritten. - # - # Nothing needs rebinding for this. A stage button carries its session - # identity in its own `value` (channel, thread, user), so the controls - # do not depend on sharing a message with the artifacts, and - # `prompt_ts` only ever meant "wherever the button was". - posted = await self._client.chat_postMessage( - channel=payload.prompt_channel_id, - thread_ts=meta.thread_ts or None, - text=response_payload.get("text", "") or "", - **( - {"blocks": response_payload["blocks"]} - if response_payload.get("blocks") - else {} - ), - ) - - # The button's own message becomes a short, bounded receipt. It is the - # one message still being edited, so it must stay small enough that the - # edit cannot fail -- it is also the only place a failure can be - # reported once the artifacts have moved out. - await self._client.chat_update( - channel=payload.prompt_channel_id, - ts=payload.prompt_ts, - text=_stage_action_receipt_text(action), - blocks=[build_text_section_block(text=_stage_action_receipt_text(action))], - ) - return posted - - -def _stage_action_receipt_text(action: str) -> str: - """What the button's message says once its result has been posted below. - - Deliberately fixed-size, and named after the action rather than the stage: - the button's metadata carries channel, thread and user, not which stage it - belongs to, and inventing a stage name here would be a guess rendered as - fact. This is the only message still edited after decomposition, so - anything that grows with the plan reintroduces the failure the split - exists to remove. - """ - return f":white_check_mark: {action} β€” result posted below." - - -def _slack_payload_from_result(result: Any) -> dict[str, Any]: - """Convert runtime results into Slack ``chat.update`` payload fields.""" - chat_message = getattr(result, "chat_message", None) or result - if hasattr(chat_message, "blocks") and hasattr(chat_message, "text"): - blocks = getattr(chat_message, "blocks", None) - text = getattr(chat_message, "text", None) - if blocks is not None: - return {"text": text or "", "blocks": blocks} - return {"text": text or ""} - # `chat_message` already collapsed to `result` above, so this is the whole non-Slack case. - return {"text": agent_reply_text(chat_message)} - - -def _stage_action_in_progress_text( - action: Literal["proceed", "back", "redo", "cancel"], -) -> str: - """Return short status text while a stage action is being processed.""" - labels = { - "proceed": "Proceeding to the next stage...", - "back": "Going back to the previous stage...", - "redo": "Re-running this stage...", - "cancel": "Stopping this timeboxing session...", - } - return labels.get(action, "Working on that...") - - -__all__ = [ - "FF_TIMEBOX_STAGE_PROCEED_ACTION_ID", - "FF_TIMEBOX_STAGE_BACK_ACTION_ID", - "FF_TIMEBOX_STAGE_REDO_ACTION_ID", - "FF_TIMEBOX_STAGE_CANCEL_ACTION_ID", - "TimeboxingStageActionCoordinator", - "TimeboxingStageActionPayload", - "TimeboxingStageActionMeta", - "build_stage_actions_block", -] diff --git a/src/fateforger/slack_bot/timeboxing_submit.py b/src/fateforger/slack_bot/timeboxing_submit.py deleted file mode 100644 index fde3e46f..00000000 --- a/src/fateforger/slack_bot/timeboxing_submit.py +++ /dev/null @@ -1,270 +0,0 @@ -"""Slack submit/undo controls for timeboxing Stage 5 review flow.""" - -from __future__ import annotations - -from typing import Any - -from autogen_core import AgentId -from pydantic import BaseModel -from slack_sdk.web.async_client import AsyncWebClient - -from fateforger.agents.timeboxing.messages import ( - TimeboxingCancelSubmit, - TimeboxingConfirmSubmit, - TimeboxingUndoSubmit, -) -from fateforger.slack_bot.constraint_review import decode_metadata -from fateforger.slack_bot.reply_guard import agent_reply_text - -FF_TIMEBOX_CONFIRM_SUBMIT_ACTION_ID = "ff_timebox_confirm_submit" -FF_TIMEBOX_CANCEL_SUBMIT_ACTION_ID = "ff_timebox_cancel_submit" -FF_TIMEBOX_UNDO_SUBMIT_ACTION_ID = "ff_timebox_undo_submit" - - -class TimeboxSubmitMeta(BaseModel): - """Metadata encoded into submit/undo Slack action values.""" - - channel_id: str - thread_ts: str - user_id: str - - @classmethod - def from_value(cls, value: str) -> "TimeboxSubmitMeta | None": - """Parse metadata from a button value payload.""" - raw = decode_metadata(value) - try: - return cls.model_validate( - { - "channel_id": raw.get("channel_id") or "", - "thread_ts": raw.get("thread_ts") or "", - "user_id": raw.get("user_id") or "", - } - ) - except Exception: - return None - - -class TimeboxSubmitActionPayload(BaseModel): - """Normalized Slack button action payload for submit/undo handlers.""" - - value: str - prompt_channel_id: str - prompt_ts: str - actor_user_id: str | None = None - - @classmethod - def from_action_body(cls, body: dict[str, Any]) -> "TimeboxSubmitActionPayload | None": - """Extract a typed action payload from a Slack action callback body.""" - actions = body.get("actions") or [] - action = actions[0] if isinstance(actions, list) and actions else {} - value = action.get("value") if isinstance(action, dict) else "" - channel_id = (body.get("channel") or {}).get("id") or "" - message_ts = (body.get("message") or {}).get("ts") or "" - actor_user_id = (body.get("user") or {}).get("id") - if not (value and channel_id and message_ts): - return None - try: - return cls.model_validate( - { - "value": str(value), - "prompt_channel_id": str(channel_id), - "prompt_ts": str(message_ts), - "actor_user_id": str(actor_user_id) if actor_user_id else None, - } - ) - except Exception: - return None - - -def build_review_submit_actions_block(*, meta_value: str) -> dict[str, Any]: - """Return the Stage 5 review submit/cancel action block.""" - return { - "type": "actions", - "block_id": "ff_timebox_review_actions", - "elements": [ - { - "type": "button", - "action_id": FF_TIMEBOX_CONFIRM_SUBMIT_ACTION_ID, - "text": {"type": "plain_text", "text": "Submit to Calendar"}, - "style": "primary", - "value": meta_value, - }, - { - "type": "button", - "action_id": FF_TIMEBOX_CANCEL_SUBMIT_ACTION_ID, - "text": {"type": "plain_text", "text": "Keep Editing"}, - "value": meta_value, - }, - ], - } - - -def build_undo_submit_actions_block(*, meta_value: str) -> dict[str, Any]: - """Return the post-submit undo action block.""" - return { - "type": "actions", - "block_id": "ff_timebox_post_submit_actions", - "elements": [ - { - "type": "button", - "action_id": FF_TIMEBOX_UNDO_SUBMIT_ACTION_ID, - "text": {"type": "plain_text", "text": "Undo"}, - "style": "danger", - "value": meta_value, - } - ], - } - - -def build_text_section_block(*, text: str) -> dict[str, Any]: - """Render markdown text content as a Slack section block.""" - return { - "type": "section", - "text": {"type": "mrkdwn", "text": text or "(no response)"}, - } - - -def build_markdown_block(*, text: str) -> dict[str, Any]: - """Render content using Slack's native markdown block type.""" - return { - "type": "markdown", - "text": text or "(no response)", - } - - -class TimeboxingSubmitCoordinator: - """Bridge submit/undo Slack button actions to timeboxing agent messages.""" - - def __init__(self, *, runtime: Any, client: AsyncWebClient) -> None: - """Initialize coordinator dependencies.""" - self._runtime = runtime - self._client = client - - async def handle_confirm_action( - self, *, payload: TimeboxSubmitActionPayload - ) -> None: - """Handle confirm button action by dispatching ``TimeboxingConfirmSubmit``.""" - meta = TimeboxSubmitMeta.from_value(payload.value) - if not meta: - return - await self._client.chat_update( - channel=payload.prompt_channel_id, - ts=payload.prompt_ts, - text="Submitting to calendar...", - blocks=[build_text_section_block(text="Submitting to calendar...")], - ) - msg = TimeboxingConfirmSubmit( - channel_id=meta.channel_id, - thread_ts=meta.thread_ts, - user_id=meta.user_id or (payload.actor_user_id or ""), - ) - await self._dispatch_to_timeboxing( - payload=payload, - meta=meta, - message=msg, - failure_text="Submission failed. Please try again.", - ) - - async def handle_cancel_action( - self, *, payload: TimeboxSubmitActionPayload - ) -> None: - """Handle cancel button action by dispatching ``TimeboxingCancelSubmit``.""" - meta = TimeboxSubmitMeta.from_value(payload.value) - if not meta: - return - msg = TimeboxingCancelSubmit( - channel_id=meta.channel_id, - thread_ts=meta.thread_ts, - user_id=meta.user_id or (payload.actor_user_id or ""), - ) - await self._dispatch_to_timeboxing( - payload=payload, - meta=meta, - message=msg, - failure_text="Cancel action failed. Please try again.", - ) - - async def handle_undo_action( - self, *, payload: TimeboxSubmitActionPayload - ) -> None: - """Handle undo button action by dispatching ``TimeboxingUndoSubmit``.""" - meta = TimeboxSubmitMeta.from_value(payload.value) - if not meta: - return - await self._client.chat_update( - channel=payload.prompt_channel_id, - ts=payload.prompt_ts, - text="Undoing last submission...", - blocks=[build_text_section_block(text="Undoing last submission...")], - ) - msg = TimeboxingUndoSubmit( - channel_id=meta.channel_id, - thread_ts=meta.thread_ts, - user_id=meta.user_id or (payload.actor_user_id or ""), - ) - await self._dispatch_to_timeboxing( - payload=payload, - meta=meta, - message=msg, - failure_text="Undo failed. Please try again.", - ) - - async def _dispatch_to_timeboxing( - self, - *, - payload: TimeboxSubmitActionPayload, - meta: TimeboxSubmitMeta, - message: TimeboxingConfirmSubmit | TimeboxingCancelSubmit | TimeboxingUndoSubmit, - failure_text: str, - ) -> None: - """Send a typed message to the timeboxing runtime and update Slack.""" - thread_key = f"{meta.channel_id}:{meta.thread_ts}" - try: - result = await self._runtime.send_message( - message, - recipient=AgentId("timeboxing_agent", key=thread_key), - ) - except Exception: - await self._client.chat_update( - channel=payload.prompt_channel_id, - ts=payload.prompt_ts, - text=failure_text, - blocks=[build_text_section_block(text=f":warning: {failure_text}")], - ) - return - response_payload = _slack_payload_from_result(result) - update: dict[str, Any] = { - "channel": payload.prompt_channel_id, - "ts": payload.prompt_ts, - "text": response_payload.get("text", "") or "", - } - if response_payload.get("blocks"): - update["blocks"] = response_payload["blocks"] - await self._client.chat_update(**update) - - -def _slack_payload_from_result(result: Any) -> dict[str, Any]: - """Convert agent result objects into Slack API payload fields.""" - chat_message = getattr(result, "chat_message", None) or result - if hasattr(chat_message, "blocks") and hasattr(chat_message, "text"): - blocks = getattr(chat_message, "blocks", None) - text = getattr(chat_message, "text", None) - if blocks is not None: - return {"text": text or "", "blocks": blocks} - return {"text": text or ""} - # `chat_message` already collapsed to `result` above, so this is the whole non-Slack case. - return {"text": agent_reply_text(chat_message)} - - -__all__ = [ - "FF_TIMEBOX_CONFIRM_SUBMIT_ACTION_ID", - "FF_TIMEBOX_CANCEL_SUBMIT_ACTION_ID", - "FF_TIMEBOX_UNDO_SUBMIT_ACTION_ID", - "TimeboxSubmitActionPayload", - "TimeboxSubmitMeta", - "TimeboxingSubmitCoordinator", - "build_review_submit_actions_block", - "build_undo_submit_actions_block", - "build_text_section_block", - "build_markdown_block", -] diff --git a/src/fateforger/slack_bot/topics.py b/src/fateforger/slack_bot/topics.py deleted file mode 100644 index f9857b26..00000000 --- a/src/fateforger/slack_bot/topics.py +++ /dev/null @@ -1,85 +0,0 @@ -"""Utilities to bind Slack threads to AutoGen topics.""" - -from __future__ import annotations - -import asyncio -from dataclasses import dataclass, field -from typing import Dict, Optional - -from autogen_core import TopicId - - -@dataclass -class SlackTopicBinding: - channel: str - thread_ts: str - topic_id: TopicId - intent: str - agent_type: str - queue: "asyncio.Queue[dict]" = field(default_factory=asyncio.Queue, kw_only=True) - drain_task: Optional[asyncio.Task] = field(default=None, kw_only=True) - - -class TopicRegistry: - """In-memory registry that maps Slack threads to AutoGen topics.""" - - _global: "TopicRegistry | None" = None - - def __init__(self, *, base_type: str = "slack.thread") -> None: - self._base_type = base_type - self._bindings: Dict[str, SlackTopicBinding] = {} - - @staticmethod - def make_key(channel: str, thread_ts: str) -> str: - return f"{channel}:{thread_ts}" - - @classmethod - def set_global(cls, registry: "TopicRegistry") -> None: - cls._global = registry - - @classmethod - def get_global(cls) -> "TopicRegistry | None": - return cls._global - - def _binding_by_topic(self, topic_id: TopicId) -> Optional[SlackTopicBinding]: - for binding in self._bindings.values(): - if binding.topic_id == topic_id: - return binding - return None - - def ensure_binding( - self, - channel: str, - thread_ts: str, - *, - intent: str, - agent_type: str, - ) -> SlackTopicBinding: - key = self.make_key(channel, thread_ts) - existing = self._bindings.get(key) - if existing: - return existing - - topic = TopicId(type=f"{self._base_type}.{intent}", source=key) - binding = SlackTopicBinding( - channel=channel, - thread_ts=thread_ts, - topic_id=topic, - intent=intent, - agent_type=agent_type, - queue=asyncio.Queue(), - ) - self._bindings[key] = binding - return binding - - def get(self, channel: str, thread_ts: str) -> Optional[SlackTopicBinding]: - return self._bindings.get(self.make_key(channel, thread_ts)) - - def get_by_topic(self, topic_id: TopicId) -> Optional[SlackTopicBinding]: - return self._binding_by_topic(topic_id) - - def pop(self, channel: str, thread_ts: str) -> Optional[SlackTopicBinding]: - return self._bindings.pop(self.make_key(channel, thread_ts), None) - - -__all__ = ["TopicRegistry", "SlackTopicBinding"] diff --git a/src/fateforger/sync_core/__init__.py b/src/fateforger/sync_core/__init__.py deleted file mode 100644 index adb4dea3..00000000 --- a/src/fateforger/sync_core/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Shared deterministic sync/reconciliation primitives.""" - -from .reconciliation_summary import ReconciliationSummary, summarize_reconciliation -from .submit_baseline_guard import ( - SubmitBaselineGuard, - SubmitBaselineGuardReason, - evaluate_submit_baseline_guard, -) - -__all__ = [ - "ReconciliationSummary", - "SubmitBaselineGuard", - "SubmitBaselineGuardReason", - "evaluate_submit_baseline_guard", - "summarize_reconciliation", -] diff --git a/src/fateforger/sync_core/reconciliation_summary.py b/src/fateforger/sync_core/reconciliation_summary.py deleted file mode 100644 index 7b07d9aa..00000000 --- a/src/fateforger/sync_core/reconciliation_summary.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Shared reconciliation-summary contract for deterministic op buckets.""" - -from __future__ import annotations - -from dataclasses import dataclass - -from fateforger.agents.timeboxing.calendar_reconciliation import reconcile_calendar_ops -from fateforger.agents.timeboxing.tb_models import TBPlan - - -@dataclass(frozen=True) -class ReconciliationSummary: - """Deterministic op-bucket counts for a desired-vs-remote snapshot pair.""" - - remote_fetched: int - matched: int - create: int - update: int - noop: int - delete: int - - @property - def planned_mutations(self) -> int: - return self.create + self.update + self.delete - - -def summarize_reconciliation( - *, - remote: TBPlan, - desired: TBPlan, - event_id_map: dict[str, str], - remote_event_ids_by_index: list[str] | None = None, -) -> ReconciliationSummary: - """Return deterministic reconciliation counts from canonical op planning.""" - plan = reconcile_calendar_ops( - remote=remote, - desired=desired, - event_id_map=event_id_map, - remote_event_ids_by_index=remote_event_ids_by_index, - ) - remote_fetched = len(remote.resolve_times(validate_non_overlap=False)) - return ReconciliationSummary( - remote_fetched=remote_fetched, - matched=len(plan.matches), - create=len(plan.creates), - update=len(plan.updates), - noop=len(plan.noops), - delete=len(plan.deletes), - ) diff --git a/src/fateforger/sync_core/submit_baseline_guard.py b/src/fateforger/sync_core/submit_baseline_guard.py deleted file mode 100644 index 432dac9f..00000000 --- a/src/fateforger/sync_core/submit_baseline_guard.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Shared submit-time baseline guard contract.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Literal - -SubmitBaselineGuardReason = Literal[ - "ready", - "remote_baseline_refresh_failed", - "missing_base_snapshot", -] - - -@dataclass(frozen=True) -class SubmitBaselineGuard: - """Deterministic readiness result for submit-time baseline prerequisites.""" - - ready: bool - reason: SubmitBaselineGuardReason - - -def evaluate_submit_baseline_guard( - *, - refresh_ok: bool, - has_base_snapshot: bool, -) -> SubmitBaselineGuard: - """Classify submit baseline readiness in a shared deterministic way.""" - if not refresh_ok: - return SubmitBaselineGuard( - ready=False, - reason="remote_baseline_refresh_failed", - ) - if not has_base_snapshot: - return SubmitBaselineGuard( - ready=False, - reason="missing_base_snapshot", - ) - return SubmitBaselineGuard( - ready=True, - reason="ready", - ) diff --git a/src/fateforger/tools_config/README.md b/src/fateforger/tools_config/README.md deleted file mode 100644 index cd88670f..00000000 --- a/src/fateforger/tools_config/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# Tools Config - -Shared configuration helpers for MCP tool discovery. - -Key files: -- `calendar_tools.py`: MCP server parameter builder for calendar tools. diff --git a/src/fateforger/tools_config/__init__.py b/src/fateforger/tools_config/__init__.py deleted file mode 100644 index cb02a704..00000000 --- a/src/fateforger/tools_config/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .calendar_tools import get_calendar_mcp_params - -__all__ = ["get_calendar_mcp_params"] diff --git a/src/fateforger/tools_config/calendar_tools.py b/src/fateforger/tools_config/calendar_tools.py deleted file mode 100644 index 54eedb9c..00000000 --- a/src/fateforger/tools_config/calendar_tools.py +++ /dev/null @@ -1,15 +0,0 @@ -from __future__ import annotations - -from autogen_ext.tools.mcp import StreamableHttpServerParams - -from fateforger.core.config import settings - - -def get_calendar_mcp_params(timeout: float = 10.0) -> StreamableHttpServerParams: - """Build AutoGen MCP connection parameters for the Calendar MCP server.""" - return StreamableHttpServerParams( - url=settings.mcp_calendar_server_url, timeout=timeout - ) - - -__all__ = ["get_calendar_mcp_params"] diff --git a/src/tmbx/journal/constraint_refs.py b/src/tmbx/journal/constraint_refs.py deleted file mode 100644 index 97b7e852..00000000 --- a/src/tmbx/journal/constraint_refs.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Extract journal-ready constraint references. - -Duck-typed on purpose: this module must not import ``fateforger``. -""" - -from __future__ import annotations - -from typing import Any, Iterable, Literal - -from .models import ConstraintRef - - -def constraint_refs(objects: Iterable[Any]) -> list[ConstraintRef]: - """Build ``ConstraintRef`` rows from constraint-like objects. - - A constraint carrying a minted ``hints["uid"]`` produces a ref tagged - ``uid_kind="minted"``. A constraint without one produces a ref tagged - ``uid_kind="unresolvable"`` with an empty ``uid`` β€” there is no - content-derived fallback. Hashing a constraint's name/description/ - necessity/scope to invent an identity key is banned outright (CLAUDE.md): - it decides whether two constraints mean the same thing from their text, - and on this project's own data that mechanism conflated ``Work Window`` - with ``Deep Work Block Duration``. An honest "unresolvable" beats a - plausible-looking guess. - """ - refs: list[ConstraintRef] = [] - for obj in objects or []: - hints = getattr(obj, "hints", None) - hints = hints if isinstance(hints, dict) else {} - - minted = str(hints.get("uid") or "").strip() - uid: str - kind: Literal["minted", "unresolvable"] - if minted: - uid, kind = minted, "minted" - else: - uid, kind = "", "unresolvable" - - reason = hints.get("extraction_reason") - refs.append( - ConstraintRef( - uid=uid, - uid_kind=kind, - reason=str(reason) if reason else None, - ) - ) - return refs - - -__all__ = ["constraint_refs"] diff --git a/src/tmbx/journal/instrument.py b/src/tmbx/journal/instrument.py deleted file mode 100644 index bb415fcb..00000000 --- a/src/tmbx/journal/instrument.py +++ /dev/null @@ -1,339 +0,0 @@ -# src/tmbx/journal/instrument.py -"""Decorators that journal the legacy patcher and submitter. - -Instrumentation by decoration: both wrapped objects are constructed at a -single site each, so six call sites get covered by two changed lines β€” -``apply_patch`` (x2), ``apply_patch_legacy`` (x1), ``submit_plan`` (x2) and -``undo_transaction`` (x1) in ``agent.py``. - -``apply_patch_legacy`` needs its own explicit journaling method rather than -relying on ``__getattr__`` passthrough: ``TimeboxPatcher.apply_patch_legacy`` -converts ``Timebox`` to ``TBPlan``, then calls ``self.apply_patch(...)`` on -itself β€” the *inner*, unwrapped patcher β€” and converts back. If this wrapper -only exposed ``apply_patch`` and let ``apply_patch_legacy`` fall through -``__getattr__``, the call would resolve straight to the unwrapped inner -object and produce no journal row at all, even though the call itself -succeeds. Journaling has to wrap the legacy entrypoint directly. - -Journal writes never break planning. Every write is guarded, and so is -every other piece of context-gathering that runs before or after the -wrapped call (constraint extraction, calendar-id resolution) β€” none of it -is allowed to stop the underlying patcher or submitter from doing its job. - -Neither wrapper guesses a calendar when it isn't told one. -``JournalingPatcher``'s ``calendar_id_fn`` defaults to a function that -returns ``UNRESOLVED_CALENDAR_ID``, and ``JournalingSubmitter.submit_plan`` -falls back to the same sentinel when its caller's ``**kwargs`` carries no -``calendar_id`` β€” as of this writing, no call site in ``agent.py`` passes -one to either ``submit_plan`` call, so every COMMIT row is unresolved until -a caller is wired to supply it. ``undo_transaction`` reads -``tmbx_calendar_id`` off the transaction ``submit_plan`` stamped, so an -undo of an unresolved commit stays unresolved too, rather than resolving -"backward" to a guess. Resolving any of these to a plausible-looking -default (e.g. ``"primary"``, or the installation-wide ``CalendarPreferences`` -default) would replace a visibly wrong value with a value that looks -resolved but isn't β€” a harder bug to find later, since nothing in the agent -path actually reads which calendar the session concerns. Passing a resolver -or an explicit ``calendar_id`` that returns the session's real calendar id -is the caller's responsibility. - -Legacy transaction status vocabulary differs by direction, and this module -maps each direction separately rather than assuming one success string: -``submit_plan``'s returned transaction reports ``status="committed"`` on -full success and ``"partial"``/``"partial_halted"`` otherwise -(sync_engine.py:452-458); ``undo_transaction``'s returned transaction -reports ``status="undone"`` on full success and ``"undo_partial"`` -otherwise (sync_engine.py:520-525) β€” undo re-executes compensating ops and -only remaps to ``"undone"`` once *that* inner execution reports -``"committed"``. Getting this wrong in either direction would silently -defeat the Task 3 fix that keys ``derive_dispositions``' ``undone_tx`` set -off ``outcome is PatchOutcome.APPLIED`` (disposition.py:36-40): a -partially-failed commit or undo would otherwise be journaled as a clean -success. -""" - -from __future__ import annotations - -import logging -import uuid -from datetime import date as date_type -from typing import Any, Callable, Iterable - -from .constraint_refs import constraint_refs -from .models import ConstraintRef, EntryKind, JournalEntry, PatchOutcome - -logger = logging.getLogger(__name__) - -UNRESOLVED_CALENDAR_ID = "unresolved:no-calendar-id-resolver" -"""Sentinel recorded in place of a guessed calendar id. - -``JournalEntry.calendar_id`` is a non-null indexed column, so there is no -NULL to fall back to. This string is unmistakably not a real Google Calendar -id (never "primary", never an email address), sorts and indexes like any -other value, and is stable so ``JournalStore.by_day`` can group these rows -together deliberately β€” querying by this constant is how a caller finds -"all rows where we don't know the calendar" rather than silently reading -them as if they were on "primary". -""" - - -def _plan_date(obj: Any) -> date_type: - """Best-effort plan date, falling back to today.""" - value = getattr(obj, "date", None) - return value if isinstance(value, date_type) else date_type.today() - - -def _ops_json(patch: Any) -> str: - """Serialise a patch to JSON without assuming its type.""" - dumper = getattr(patch, "model_dump_json", None) - if callable(dumper): - try: - return str(dumper()) - except Exception: - logger.warning( - "patch serialisation failed; using empty ops_json", exc_info=True - ) - return "{}" - - -def _safe_constraint_refs(constraints: Iterable[Any]) -> list[ConstraintRef]: - """Extract constraint refs, never letting the extraction break planning. - - In production ``constraints`` is ``list[Constraint]``, a SQLModel - ``table=True`` ORM object. Attribute access on a detached or expired - instance raises ``DetachedInstanceError``, which duck-typed ``getattr`` - calls inside ``constraint_refs`` do not protect against. Losing - constraint context in the journal is acceptable; failing to plan is not. - """ - try: - return constraint_refs(constraints) - except Exception: - logger.warning( - "constraint_refs failed; continuing without constraint context", - exc_info=True, - ) - return [] - - -def _status_outcome(tx: Any, success_status: str) -> PatchOutcome: - """Map a transaction's ``status`` onto a ``PatchOutcome``. - - Only the exact ``success_status`` counts as success. The legacy sync - engine can return a partial-failure status without raising, so status - must be inspected rather than assumed. A missing ``status`` attribute - (a fake, or a future transaction type) degrades to failure β€” the safe - default, since downstream disposition derivation only trusts APPLIED - outcomes to mark undo targets as undone. - """ - return ( - PatchOutcome.APPLIED - if getattr(tx, "status", None) == success_status - else PatchOutcome.APPLY_FAILED - ) - - -class JournalingPatcher: - """Wrap a patcher, recording one attempt row per ``apply_patch`` call.""" - - def __init__( - self, - inner: Any, - store: Any, - calendar_id_fn: Callable[[], str] = lambda: UNRESOLVED_CALENDAR_ID, - ) -> None: - self._inner = inner - self._store = store - self._calendar_id_fn = calendar_id_fn - - def __getattr__(self, name: str) -> Any: - """Pass through everything not explicitly wrapped.""" - return getattr(self._inner, name) - - async def _write(self, entry: JournalEntry) -> None: - try: - await self._store.append(entry) - except Exception: - logger.warning("journal write failed; continuing", exc_info=True) - - def _resolve_calendar_id(self) -> str: - try: - return self._calendar_id_fn() - except Exception: - logger.warning( - "calendar_id_fn raised; recording calendar_id as unresolved " - "rather than guessing", - exc_info=True, - ) - return UNRESOLVED_CALENDAR_ID - - async def apply_patch(self, **kwargs: Any) -> Any: - current = kwargs.get("current") - constraints: Iterable[Any] = kwargs.get("constraints") or [] - instruction = kwargs.get("user_message") - - base = dict( - calendar_id=self._resolve_calendar_id(), - plan_date=_plan_date(current), - instruction=instruction, - kind=EntryKind.ATTEMPT, - ) - refs = _safe_constraint_refs(constraints) - - try: - result = await self._inner.apply_patch(**kwargs) - except Exception as exc: - entry = JournalEntry( - **base, outcome=PatchOutcome.APPLY_FAILED, error=str(exc)[:2000] - ) - entry.set_constraints(refs) - await self._write(entry) - raise - - _, patch = result - entry = JournalEntry( - **base, outcome=PatchOutcome.APPLIED, ops_json=_ops_json(patch) - ) - entry.set_constraints(refs) - await self._write(entry) - return result - - async def apply_patch_legacy(self, **kwargs: Any) -> Any: - """Journal the ``Timebox``-in/``Timebox``-out legacy patch path. - - Must call ``self._inner.apply_patch_legacy`` directly rather than - delegating to this wrapper's own ``apply_patch``: the inner - ``apply_patch_legacy`` already does its own TBPlan conversion and - calls ``self.apply_patch(...)`` on *itself* (the unwrapped inner - object), so routing through the wrapper here would double-convert - and still bypass journaling on the inner call. See the module - docstring. - - The legacy interface returns a ``Timebox`` directly rather than a - ``(TBPlan, TBPatch)`` tuple, so there is no patch object to - serialise β€” ``ops_json`` is recorded as ``"{}"``. - """ - current = kwargs.get("current") - constraints: Iterable[Any] = kwargs.get("constraints") or [] - instruction = kwargs.get("user_message") - - base = dict( - calendar_id=self._resolve_calendar_id(), - plan_date=_plan_date(current), - instruction=instruction, - kind=EntryKind.ATTEMPT, - ) - refs = _safe_constraint_refs(constraints) - - try: - result = await self._inner.apply_patch_legacy(**kwargs) - except Exception as exc: - entry = JournalEntry( - **base, outcome=PatchOutcome.APPLY_FAILED, error=str(exc)[:2000] - ) - entry.set_constraints(refs) - await self._write(entry) - raise - - entry = JournalEntry(**base, outcome=PatchOutcome.APPLIED, ops_json="{}") - entry.set_constraints(refs) - await self._write(entry) - return result - - -class JournalingSubmitter: - """Wrap a submitter, recording commit and undo rows. - - Stamps ``tmbx_tx_id``, ``tmbx_calendar_id`` and ``tmbx_plan_date`` onto - each returned transaction so a later undo can reference the commit it - reverses and land its journal row on the right calendar-day. - """ - - def __init__(self, inner: Any, store: Any) -> None: - self._inner = inner - self._store = store - - def __getattr__(self, name: str) -> Any: - return getattr(self._inner, name) - - async def _write(self, entry: JournalEntry) -> None: - try: - await self._store.append(entry) - except Exception: - logger.warning("journal write failed; continuing", exc_info=True) - - async def submit_plan(self, desired: Any, **kwargs: Any) -> Any: - calendar_id = kwargs.get("calendar_id", UNRESOLVED_CALENDAR_ID) - plan_date = _plan_date(desired) - - try: - tx = await self._inner.submit_plan(desired, **kwargs) - except Exception as exc: - entry = JournalEntry( - calendar_id=calendar_id, - plan_date=plan_date, - kind=EntryKind.COMMIT, - outcome=PatchOutcome.APPLY_FAILED, - error=str(exc)[:2000], - ) - await self._write(entry) - raise - - tx_id = uuid.uuid4().hex - try: - setattr(tx, "tmbx_tx_id", tx_id) - setattr(tx, "tmbx_calendar_id", calendar_id) - setattr(tx, "tmbx_plan_date", plan_date) - except Exception: # pragma: no cover - defensive - pass - - entry = JournalEntry( - calendar_id=calendar_id, - plan_date=plan_date, - kind=EntryKind.COMMIT, - outcome=_status_outcome(tx, "committed"), - tx_id=tx_id, - ) - await self._write(entry) - return tx - - async def undo_transaction(self, tx: Any) -> Any: - calendar_id = getattr(tx, "tmbx_calendar_id", UNRESOLVED_CALENDAR_ID) - plan_date = getattr(tx, "tmbx_plan_date", date_type.today()) - undoes_tx = getattr(tx, "tmbx_tx_id", None) - - try: - undo_tx = await self._inner.undo_transaction(tx) - except Exception as exc: - entry = JournalEntry( - calendar_id=calendar_id, - plan_date=plan_date, - kind=EntryKind.UNDO, - outcome=PatchOutcome.APPLY_FAILED, - error=str(exc)[:2000], - undoes_tx=undoes_tx, - ) - await self._write(entry) - raise - - if undo_tx is None: - return None - - entry = JournalEntry( - calendar_id=calendar_id, - plan_date=plan_date, - kind=EntryKind.UNDO, - outcome=_status_outcome(undo_tx, "undone"), - tx_id=uuid.uuid4().hex, - undoes_tx=undoes_tx, - ) - await self._write(entry) - return undo_tx - - async def undo_last(self) -> Any: - tx = getattr(self._inner, "last_transaction", None) - if tx is None: - return await self._inner.undo_last() - return await self.undo_transaction(tx) - - -__all__ = ["UNRESOLVED_CALENDAR_ID", "JournalingPatcher", "JournalingSubmitter"] diff --git a/tests/README.md b/tests/README.md index 85d1b072..db3a34de 100644 --- a/tests/README.md +++ b/tests/README.md @@ -143,6 +143,14 @@ modules themselves is a separate, out-of-scope change with its own PR (the evidence for each is in that PR's body); this one only removed the tests that had nothing left to guard. +The legacy `TimeboxingFlowAgent` was retired on 2026-09-09 with the 34 +modules only it reached; 67 test files went with it in the retirement +commit, and 77 across the branch, because their subject was that code (the +list is in the retirement PR); two were rewired to the harness's readers +instead. The rule that decided each: *if the subject is deleted code, the +test goes; if the subject is live and the deleted module was only a +fixture, the test is rewired.* + `CALENDAR_QUERY_LOCATIONS.md` and `MIGRATION_ARCHIVE_TO_CALENDAR_HAUNTER.md` at the repo root document the pre-AutoGen `CalendarHaunter` (`admonisher.calendar`) as production; nothing has constructed it since haunting moved to diff --git a/tests/conftest.py b/tests/conftest.py index 3bb2cfcf..2873da73 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -72,17 +72,3 @@ async def scheduler(): yield sched sched.shutdown(wait=False) - -@pytest.fixture(autouse=True) -def _timebox_backend_is_legacy_unless_asked(monkeypatch): - """Keep the suite off the harness. - - Routing timeboxing to DSH means route_slack_event would otherwise spawn a - real harness subprocess per test -- the suite went from 15s to 7m36s and - started depending on a node install, a profile directory and two symlinks - outside this repo. A test that silently shells out is not a unit test. - - Tests that want the harness path set the variable themselves and stub - `_harness_turn`, so reaching it is always a deliberate act. - """ - monkeypatch.setenv("FF_TIMEBOX_BACKEND", "legacy") diff --git a/tests/doubles/timeboxing.py b/tests/doubles/timeboxing.py index 7ddc8e32..80b4ba8c 100644 --- a/tests/doubles/timeboxing.py +++ b/tests/doubles/timeboxing.py @@ -130,10 +130,12 @@ def _kernel( ) -def _advance_request(*, expected_revision: int = 3) -> TurnRequest: +def _advance_request( + *, expected_revision: int = 3, interaction_id: str = "1772.2" +) -> TurnRequest: return TurnRequest( session_key="C1:1.0", - interaction_id="1772.2", + interaction_id=interaction_id, actor_user_id="U1", expected_revision=expected_revision, intent=Advance(), diff --git a/tests/e2e/test_slack_timebox_command.py b/tests/e2e/test_slack_timebox_command.py index 78568549..870d019c 100644 --- a/tests/e2e/test_slack_timebox_command.py +++ b/tests/e2e/test_slack_timebox_command.py @@ -1,7 +1,6 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable from typing import Any import pytest @@ -23,7 +22,6 @@ async def _fake_route_slack_event( bot_user_id: str, say: Any, client: Any, - get_constraint_store: Callable[[], Awaitable[Any]], ) -> None: """Capture the routed event args.""" captured["default_agent"] = default_agent @@ -37,10 +35,6 @@ async def _respond(**payload: Any) -> None: """Capture ephemeral responses.""" responses.append(payload) - async def _get_constraint_store() -> Any: - """Return no constraint store for this test.""" - return None - await handlers_mod._handle_timebox_command( runtime=object(), focus=object(), @@ -48,7 +42,6 @@ async def _get_constraint_store() -> Any: body={"user_id": "U1", "channel_id": "C1", "text": "tomorrow"}, client=object(), respond=_respond, - get_constraint_store=_get_constraint_store, ) assert captured["default_agent"] == "timeboxing_agent" @@ -71,17 +64,12 @@ async def _fake_route_slack_event( bot_user_id: str, say: Any, client: Any, - get_constraint_store: Callable[[], Awaitable[Any]], ) -> None: """Capture the routed event args.""" captured["event"] = event monkeypatch.setattr(handlers_mod, "route_slack_event", _fake_route_slack_event) - async def _get_constraint_store() -> Any: - """Return no constraint store for this test.""" - return None - await handlers_mod._handle_timebox_command( runtime=object(), focus=object(), @@ -89,7 +77,6 @@ async def _get_constraint_store() -> Any: body={"user_id": "U1", "channel_id": "D123", "text": "today"}, client=object(), respond=None, - get_constraint_store=_get_constraint_store, ) assert captured["event"]["channel_type"] == "im" @@ -144,7 +131,6 @@ async def test_timebox_on_the_harness_backend_asks_for_the_day_not_deepseek( fresh process pick its own planning day before anybody confirmed one. Both backends now enter through the same thread/redirect machinery. """ - monkeypatch.setenv("FF_TIMEBOX_BACKEND", "harness") timebox_calls: list[dict[str, Any]] = [] diff --git a/tests/e2e/test_slack_timeboxing_background_status.py b/tests/e2e/test_slack_timeboxing_background_status.py deleted file mode 100644 index b398ff07..00000000 --- a/tests/e2e/test_slack_timeboxing_background_status.py +++ /dev/null @@ -1,60 +0,0 @@ -import pytest - -pytest.importorskip("autogen_agentchat") - -from autogen_agentchat.messages import TextMessage - -from fateforger.slack_bot.focus import FocusManager -from fateforger.slack_bot.handlers import route_slack_event - - -class _FakeRuntime: - def __init__(self, response_text: str): - self._response = TextMessage(content=response_text, source="timeboxing_agent") - self.calls = [] - - async def send_message(self, message, recipient): - self.calls.append(recipient.type) - return self._response - - -class _FakeClient: - def __init__(self): - self.posted = [] - self.updates = [] - - async def chat_postMessage(self, **payload): - self.posted.append(payload) - return {"channel": payload["channel"], "ts": "p1"} - - async def chat_update(self, **payload): - self.updates.append(payload) - return {"ok": True} - - -async def _unused_say(**_kwargs): - return {"channel": "C1", "ts": "unused"} - - -@pytest.mark.asyncio -async def test_slack_updates_include_background_status_text(): - focus = FocusManager(ttl_seconds=60, allowed_agents=["timeboxing_agent"]) - focus.set_focus("C1:111", "timeboxing_agent", by_user="U1") - runtime = _FakeRuntime( - "Stage 1/5 (CollectConstraints)\nSummary:\n- ok\nBackground:\n- Syncing" - ) - client = _FakeClient() - - await route_slack_event( - runtime=runtime, - focus=focus, - default_agent="receptionist_agent", - event={"channel": "C1", "user": "U1", "text": "plan", "ts": "111"}, - bot_user_id=None, - say=_unused_say, - client=client, - ) - - assert runtime.calls == ["timeboxing_agent"] - assert client.updates - assert "Background:" in (client.updates[-1].get("text") or "") diff --git a/tests/honest_allowlist.py b/tests/honest_allowlist.py new file mode 100644 index 00000000..727852c6 --- /dev/null +++ b/tests/honest_allowlist.py @@ -0,0 +1,230 @@ +"""Every file where a test still reaches past a public interface, and why. + +Generated when the legacy agent was retired (2026-09); shrinks in the +composability work that follows. An entry is ``"": +(count, "")`` -- ``count`` is the number of offending sites the file +carries today, and it is a ceiling: the guard's ratchet test fails if a file +ever needs less than its listed count (fix a site, lower the number) or +needs none at all (remove the entry). It never fails for needing more -- +that's the file-level check, and it's what keeps the list from growing back. +""" + +ALLOWED: dict[str, tuple[int, str]] = { + "tests/memory/test_anchor_graph.py": ( + 1, + "swaps MemoryService's judge mid-test to change judge behaviour between two calls " + "(e.g. a read-path judge that must not be asked) while keeping the same DB-backed " + "observation/constraint state; the constructor takes a judge but the tests need to " + "change it after writes have already happened.", + ), + "tests/memory/test_concurrent_ingest.py": ( + 1, + "swaps MemoryService's judge mid-test to change judge behaviour between two calls " + "(e.g. a read-path judge that must not be asked) while keeping the same DB-backed " + "observation/constraint state; the constructor takes a judge but the tests need to " + "change it after writes have already happened.", + ), + "tests/memory/test_idempotent_write.py": ( + 5, + "swaps MemoryService's judge mid-test to change judge behaviour between two calls " + "(e.g. a read-path judge that must not be asked) while keeping the same DB-backed " + "observation/constraint state; the constructor takes a judge but the tests need to " + "change it after writes have already happened.", + ), + "tests/memory/test_necessity.py": ( + 1, + "swaps MemoryService's judge mid-test to change judge behaviour between two calls " + "(e.g. a read-path judge that must not be asked) while keeping the same DB-backed " + "observation/constraint state; the constructor takes a judge but the tests need to " + "change it after writes have already happened.", + ), + "tests/memory/test_reprojection.py": ( + 1, + "swaps MemoryService's judge mid-test to change judge behaviour between two calls " + "(e.g. a read-path judge that must not be asked) while keeping the same DB-backed " + "observation/constraint state; the constructor takes a judge but the tests need to " + "change it after writes have already happened.", + ), + "tests/memory/test_sampling.py": ( + 1, + "swaps MemoryService's judge mid-test to change judge behaviour between two calls " + "(e.g. a read-path judge that must not be asked) while keeping the same DB-backed " + "observation/constraint state; the constructor takes a judge but the tests need to " + "change it after writes have already happened.", + ), + "tests/unit/constraints/test_constraint_memory_client_strict_failures.py": ( + 6, + "ConstraintMemoryClient.__init__ builds a real stdio McpWorkbench subprocess with no " + "injectable workbench parameter; __new__ skips that to wire a fake workbench directly " + "for MCP tool-error-text unit tests.", + ), + "tests/unit/constraints/test_notion_constraint_store_schema_compat.py": ( + 1, + "NotionConstraintStore.__init__ builds a real Notion database client with no " + "injectable schema parameter; __new__ skips that to attach a bare SimpleNamespace " + "schema for property-alias resolution tests.", + ), + "tests/unit/constraints/test_timeboxing_constraint_memory_client_tool_name.py": ( + 21, + "ConstraintMemoryClient.__init__ builds a real stdio McpWorkbench subprocess with no " + "injectable workbench parameter; __new__ skips that to wire a fake workbench directly, " + "and the local _DummyWorkbench double's canned _result is reconfigured per test " + "instead of being passed to its constructor.", + ), + "tests/unit/core/test_llm_audit_pipeline.py": ( + 5, + "resets logging_config's private module-level LLM-audit singletons " + "(_LLM_AUDIT_THREAD/_QUEUE/_SINK) between tests for isolation; the module holds this " + "as private global state with no public reset function.", + ), + "tests/unit/core/test_mcp_url_validation.py": ( + 4, + "NotionMcpClient.__init__ probes a real MCP endpoint over the network with no " + "injectable params; __new__ skips that to set _params/_server_url/_timeout directly " + "for a probe-failure unit test.", + ), + "tests/unit/core/test_observability_metrics.py": ( + 2, + "swaps logging_config's private _METRIC_ADMONISHMENTS Prometheus counter to None and " + "back, to test that recording is a no-op before metrics are initialized; the module " + "exposes no public accessor for this state.", + ), + "tests/unit/haunt/test_receptionist_handoff_message.py": ( + 1, + "ReceptionistAgent.__init__ builds a real AutoGen AssistantAgent with no injectable " + "one; the test constructs through the real constructor then swaps _assistant to an " + "in-process fake, since there is no constructor seam for it.", + ), + "tests/unit/haunt/test_reconcile.py": ( + 7, + "reconfigures DummyCalendarClient's canned _events between two reconcile calls " + "instead of rebuilding the double, and constructs McpCalendarClient via " + "__new__/object.__new__ to inject a fake _workbench, since its __init__ builds a real " + "MCP workbench subprocess with no injectable parameter.", + ), + "tests/unit/haunt/test_reconcile_two_rules.py": ( + 5, + "reconfigures _RequiredRule's canned _windows/_undecided between two reconcile calls " + "instead of rebuilding the double, and swaps PlanningReconciler's " + "_required_block_rule mid-test even though the constructor accepts one, to preserve " + "already-scheduled jobs across the swap.", + ), + "tests/unit/schedular/test_planner_agent_return_type.py": ( + 1, + "PlannerAgent.__init__ builds real AutoGen assistant/MCP tooling with no injectable " + "delegate; the test constructs through the real constructor then swaps _delegate to " + "an in-process fake assistant to skip that initialization.", + ), + "tests/unit/schedular/test_planner_upsert_verification.py": ( + 5, + "PlannerAgent.__init__ builds a real MCP workbench with no injectable parameter; " + "tests construct through the real constructor then swap _workbench to a fake to " + "control tool responses.", + ), + "tests/unit/schedular/test_revisor_handoff_wiring.py": ( + 7, + "RevisorAgent.__init__ builds real AutoGen assistants (intent/general/guided) with no " + "injectable parameter; tests construct through the real constructor then swap " + "_intent_assistant/_assistant/_guided_assistant to in-process fakes.", + ), + "tests/unit/slack/test_planning_add_to_calendar_flow.py": ( + 17, + "PlanningCoordinator.__init__ takes runtime/focus/client but not draft_store/" + "guardian/planning_session_store/anchor_store/intent_interpreter; tests construct " + "through the real constructor then wire those five dependencies directly, since there " + "is no factory parameter for them yet.", + ), + "tests/unit/slack/test_planning_session_dispatch.py": ( + 2, + "resets WorkspaceRegistry's private module-level _global singleton directly to " + "restore or clear workspace state between tests; the registry exposes set_global() to " + "write it but no public way to clear or read back the raw value for teardown.", + ), + "tests/unit/slack/test_slack_thread_memory.py": ( + 4, + "resets thread_memory's private module-level _SESSION/_SESSION_FAILED singletons " + "directly between tests for isolation; the module holds this as private global state " + "with no public reset function.", + ), + "tests/unit/slack/test_thread_reply_add_reports_back_to_the_thread.py": ( + 3, + "PlanningCoordinator.__init__ takes runtime/focus/client but not draft_store/" + "guardian/planning_session_store; the test constructs through the real constructor " + "then wires those three dependencies directly, since there is no factory parameter " + "for them yet.", + ), + "tests/unit/slack/test_tmbx_client_commit.py": ( + 5, + "TmbxClient.__init__ builds a real network MCP client with no injectable parameter; " + "the test constructs through the real constructor then swaps _client to a fake " + "transport double to control tool responses and simulate a transient timeout retry.", + ), + "tests/unit/tasks/test_task_defaults_memory.py": ( + 1, + "TaskDefaultsMemoryStore.__init__ builds its real backing store with no injectable " + "parameter; the test constructs through the real constructor then swaps _store to a " + "fake that always raises, to test the failure/caching path.", + ), + "tests/unit/tasks/test_tasks_guided_refinement_session.py": ( + 4, + "TasksAgent.__init__ builds a real AutoGen guided-refinement assistant with no " + "injectable parameter; tests swap _guided_assistant to an in-process fake, and one " + "test seeds _guided_session directly to jump straight into the CLOSE phase, since " + "there is no public setter for session state.", + ), + "tests/unit/tasks/test_tasks_notion_sprint_tools.py": ( + 2, + "NotionSprintManager exposes no public hook to intercept individual MCP tool calls or " + "override its parallelism knob; tests swap the private _call_tool_alias method and " + "_dry_run_patch_parallelism attribute directly to measure concurrent in-flight calls.", + ), + "tests/unit/tasks/test_tasks_ticktick_list_tools.py": ( + 6, + "TickTickListManager exposes no public hook to override its MCP-backed project/task " + "lookups or its snapshot parallelism; tests swap the private _list_projects/" + "_list_project_tasks methods and _pending_snapshot_parallelism attribute directly to " + "inject failures and measure concurrency.", + ), + "tests/unit/tasks/test_ticktick_mcp_client.py": ( + 8, + "TickTickMcpClient.__init__ probes a real MCP endpoint over the network with no " + "injectable params; __new__ skips that to set _params/_server_url/_timeout directly " + "for loader-failure unit tests.", + ), + "tests/unit/timeboxing/test_candidate_must_be_applied.py": ( + 6, + "DeepSeekTimeboxPlanner.__init__ builds its real tmbx client, constraint reader, " + "harness runner and clock with no injectable parameters; __new__ skips that to wire " + "five in-process fakes directly for a candidate-not-applied contract test.", + ), + "tests/unit/timeboxing/test_deepseek_timebox_planner.py": ( + 4, + "TmbxClient.__init__ builds a real MCP client with no injectable parameter; tests " + "build via __new__/object.__new__ and set _client directly to a recorded/sequenced " + "fake transport to pin exact tool-call shapes and retry behaviour.", + ), + "tests/unit/timeboxing/test_mcp_workbench_shutdown.py": ( + 2, + "McpCalendarClient.__init__ builds a real MCP workbench subprocess with no injectable " + "parameter; the test builds via object.__new__ and sets _workbench directly to a fake " + "that records stop()/close() calls.", + ), + "tests/unit/timeboxing/test_runtime_shutdown.py": ( + 6, + "swaps the timeboxing runtime module's private module-level _runtime singleton to a " + "fake and restores it, since shutdown_runtime() reads that private global directly " + "and the module exposes no public setter for tests.", + ), + "tests/unit/timeboxing/test_stage_card_registry.py": ( + 1, + "sets the local _PostingClient double's _fail flag after construction to trigger its " + "failure branch on the next call, instead of building a second double or passing the " + "mode to its constructor.", + ), + "tests/unit/timeboxing/test_timeboxing_notion_query_read_only_topics.py": ( + 3, + "NotionConstraintStore.__init__ builds real Notion database clients with no " + "injectable parameter; __new__ skips that to attach fake topics_db/constraints_db " + "doubles for read-only topic-resolution tests.", + ), +} diff --git a/tests/integration/test_harness_timeboxing_session_route.py b/tests/integration/test_harness_timeboxing_session_route.py index 6962774e..80a52999 100644 --- a/tests/integration/test_harness_timeboxing_session_route.py +++ b/tests/integration/test_harness_timeboxing_session_route.py @@ -183,11 +183,6 @@ async def conversations_replies(self, **_payload: Any) -> dict: ) -@pytest.fixture(autouse=True) -def _harness_backend(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("FF_TIMEBOX_BACKEND", "harness") - - @pytest.fixture(autouse=True) def _fresh_pending_candidates(monkeypatch: pytest.MonkeyPatch) -> None: """Pending candidates live in a module global keyed by session. diff --git a/tests/integration/test_slack_timebox_buttons.py b/tests/integration/test_slack_timebox_buttons.py deleted file mode 100644 index 1d07e103..00000000 --- a/tests/integration/test_slack_timebox_buttons.py +++ /dev/null @@ -1,217 +0,0 @@ -"""Integration tests for Slack timeboxing submit/undo button flow.""" - -from __future__ import annotations - -from dataclasses import dataclass -from datetime import date, time, timedelta -from types import SimpleNamespace -from typing import Any -from unittest.mock import AsyncMock - -import pytest - -pytest.importorskip("autogen_agentchat") - -from autogen_core import AgentId - -from fateforger.agents.schedular.models.calendar import CalendarEvent, EventType -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.messages import ( - TimeboxingCancelSubmit, - TimeboxingConfirmSubmit, - TimeboxingUndoSubmit, -) -from fateforger.agents.timeboxing.stage_gating import TimeboxingStage -from fateforger.agents.timeboxing.sync_engine import SyncOp, SyncOpType, SyncTransaction -from fateforger.agents.timeboxing.tb_models import TBPlan -from fateforger.agents.timeboxing.timebox import Timebox, timebox_to_tb_plan -from fateforger.slack_bot.constraint_review import encode_metadata -from fateforger.slack_bot.timeboxing_submit import ( - FF_TIMEBOX_CONFIRM_SUBMIT_ACTION_ID, - FF_TIMEBOX_UNDO_SUBMIT_ACTION_ID, - TimeboxSubmitActionPayload, - TimeboxingSubmitCoordinator, -) - - -class _Ctx: - topic_id = None - sender = None - - -@dataclass -class _RecordedCall: - """Captured runtime message dispatch record.""" - - message: Any - recipient: AgentId - - -class _Runtime: - """Route coordinator messages directly into a real timeboxing agent session.""" - - def __init__(self, *, agent: TimeboxingFlowAgent) -> None: - self._agent = agent - self.calls: list[_RecordedCall] = [] - - async def send_message(self, message: Any, recipient: AgentId) -> Any: - self.calls.append(_RecordedCall(message=message, recipient=recipient)) - if isinstance(message, TimeboxingConfirmSubmit): - return await self._agent.on_confirm_submit(message, _Ctx()) - if isinstance(message, TimeboxingCancelSubmit): - return await self._agent.on_cancel_submit(message, _Ctx()) - if isinstance(message, TimeboxingUndoSubmit): - return await self._agent.on_undo_submit(message, _Ctx()) - raise AssertionError(f"Unexpected message type: {type(message)}") - - -class _Client: - """Minimal Slack client stub for chat_update assertions.""" - - def __init__(self) -> None: - self.updates: list[dict[str, Any]] = [] - - async def chat_update(self, **payload: Any) -> dict[str, Any]: - self.updates.append(payload) - return {"ok": True} - - -def _build_plan(*, summary: str = "Focus") -> TBPlan: - """Create a deterministic plan fixture for submit/undo tests.""" - timebox = Timebox( - events=[ - CalendarEvent( - summary=summary, - event_type=EventType.DEEP_WORK, - start_time=time(9, 0), - duration=timedelta(minutes=90), - ) - ], - date=date(2026, 2, 13), - timezone="Europe/Amsterdam", - ) - return timebox_to_tb_plan(timebox) - - -def _build_submit_tx() -> SyncTransaction: - """Create a committed sync transaction for submit button tests.""" - return SyncTransaction( - ops=[ - SyncOp( - op_type=SyncOpType.CREATE, - gcal_event_id="fftb123", - after_payload={ - "calendarId": "primary", - "eventId": "fftb123", - "summary": "Focus", - "start": "2026-02-13T09:00:00+01:00", - "end": "2026-02-13T10:30:00+01:00", - }, - ) - ], - status="committed", - ) - - -def _action_payload(*, action_id: str) -> TimeboxSubmitActionPayload: - """Build a typed submit-button payload from a Slack-like action body.""" - meta = encode_metadata({"channel_id": "C1", "thread_ts": "T1", "user_id": "U1"}) - body = { - "actions": [{"action_id": action_id, "value": meta}], - "channel": {"id": "C1"}, - "message": {"ts": "M1"}, - "user": {"id": "U1"}, - } - payload = TimeboxSubmitActionPayload.from_action_body(body) - assert payload is not None - return payload - - -def _build_agent_session() -> TimeboxingFlowAgent: - """Return a minimally wired timeboxing agent with one active session.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._sessions = {} - agent._calendar_submitter = SimpleNamespace( - submit_plan=AsyncMock(return_value=_build_submit_tx()), - undo_transaction=AsyncMock(return_value=SyncTransaction(status="undone")), - ) - session = Session(thread_ts="T1", channel_id="C1", user_id="U1") - session.tz_name = "Europe/Amsterdam" - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = True - session.tb_plan = _build_plan() - session.base_snapshot = _build_plan(summary="Base") - agent._sessions["T1"] = session - return agent - - -@pytest.mark.asyncio -async def test_confirm_button_submits_and_exposes_undo() -> None: - """Confirm action should submit and update Slack with an Undo button.""" - agent = _build_agent_session() - runtime = _Runtime(agent=agent) - client = _Client() - coordinator = TimeboxingSubmitCoordinator(runtime=runtime, client=client) - - await coordinator.handle_confirm_action( - payload=_action_payload(action_id=FF_TIMEBOX_CONFIRM_SUBMIT_ACTION_ID) - ) - - assert runtime.calls - assert isinstance(runtime.calls[-1].message, TimeboxingConfirmSubmit) - assert agent._sessions["T1"].pending_submit is False - final_update = client.updates[-1] - action_ids = [ - element.get("action_id") - for block in final_update.get("blocks", []) - for element in block.get("elements", []) - ] - assert FF_TIMEBOX_UNDO_SUBMIT_ACTION_ID in action_ids - - -@pytest.mark.asyncio -async def test_undo_button_reverts_session_to_refine() -> None: - """Undo action should restore session state and remove Undo button.""" - agent = _build_agent_session() - agent._sessions["T1"].pending_submit = False - agent._sessions["T1"].last_sync_transaction = _build_submit_tx() - agent._sessions["T1"].last_sync_event_id_map = {"Base|09:00:00": "fftbbase"} - agent._sessions["T1"].event_id_map = {"Focus|09:00:00": "fftb123"} - - runtime = _Runtime(agent=agent) - client = _Client() - coordinator = TimeboxingSubmitCoordinator(runtime=runtime, client=client) - - await coordinator.handle_undo_action( - payload=_action_payload(action_id=FF_TIMEBOX_UNDO_SUBMIT_ACTION_ID) - ) - - session = agent._sessions["T1"] - assert session.stage == TimeboxingStage.REFINE - assert session.last_sync_transaction is None - final_update = client.updates[-1] - action_ids = [ - element.get("action_id") - for block in final_update.get("blocks", []) - for element in block.get("elements", []) - ] - assert FF_TIMEBOX_UNDO_SUBMIT_ACTION_ID not in action_ids - - -@pytest.mark.asyncio -async def test_undo_button_rejected_after_session_end() -> None: - """Undo action should be rejected when session was already ended.""" - agent = _build_agent_session() - agent._sessions["T1"].completed = True - agent._sessions["T1"].last_sync_transaction = _build_submit_tx() - - runtime = _Runtime(agent=agent) - client = _Client() - coordinator = TimeboxingSubmitCoordinator(runtime=runtime, client=client) - - await coordinator.handle_undo_action( - payload=_action_payload(action_id=FF_TIMEBOX_UNDO_SUBMIT_ACTION_ID) - ) - - final_text = client.updates[-1]["text"] - assert "already ended" in final_text diff --git a/tests/integration/test_slack_timebox_stage_buttons.py b/tests/integration/test_slack_timebox_stage_buttons.py deleted file mode 100644 index cde7b411..00000000 --- a/tests/integration/test_slack_timebox_stage_buttons.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Integration tests for deterministic timeboxing stage-control buttons.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any - -import pytest - -pytest.importorskip("autogen_agentchat") - -from autogen_agentchat.messages import TextMessage -from autogen_core import AgentId - -from fateforger.agents.timeboxing.messages import TimeboxingStageAction -from fateforger.slack_bot.constraint_review import encode_metadata -from fateforger.slack_bot.timeboxing_stage_actions import ( - FF_TIMEBOX_STAGE_PROCEED_ACTION_ID, - TimeboxingStageActionCoordinator, - TimeboxingStageActionPayload, -) - - -@dataclass -class _RecordedCall: - """Captured runtime dispatch.""" - - message: Any - recipient: AgentId - - -class _Runtime: - """Minimal runtime stub that records and returns a presenter-like response.""" - - def __init__(self) -> None: - self.calls: list[_RecordedCall] = [] - - async def send_message(self, message: Any, recipient: AgentId) -> Any: - self.calls.append(_RecordedCall(message=message, recipient=recipient)) - return TextMessage(content="Stage 2/5 (CaptureInputs)", source="timeboxing_agent") - - -class _Client: - """Slack client stub for chat_update assertions.""" - - def __init__(self) -> None: - self.updates: list[dict[str, Any]] = [] - self.posts: list[dict[str, Any]] = [] - - async def chat_update(self, **payload: Any) -> dict[str, Any]: - self.updates.append(payload) - return {"ok": True} - - async def chat_postMessage(self, **payload: Any) -> dict[str, Any]: - self.posts.append(payload) - return {"ok": True, "ts": "M2", "channel": payload.get("channel")} - - -def _action_payload() -> TimeboxingStageActionPayload: - """Build stage-action payload from a Slack action body fixture.""" - meta = encode_metadata({"channel_id": "C1", "thread_ts": "T1", "user_id": "U1"}) - body = { - "actions": [{"action_id": FF_TIMEBOX_STAGE_PROCEED_ACTION_ID, "value": meta}], - "channel": {"id": "C1"}, - "message": {"ts": "M1"}, - "user": {"id": "U1"}, - } - payload = TimeboxingStageActionPayload.from_action_body(body) - assert payload is not None - return payload - - -@pytest.mark.asyncio -async def test_stage_proceed_button_dispatches_and_posts_the_result() -> None: - """Proceed dispatches the typed action, then posts the result separately. - - Renamed from ..._and_replaces_message: replacing the prompt with the stage - result is what grew one message until Slack refused to edit it. The result - now arrives as its own threaded message and the prompt becomes a fixed-size - receipt. - """ - runtime = _Runtime() - client = _Client() - coordinator = TimeboxingStageActionCoordinator(runtime=runtime, client=client) - - await coordinator.handle_action(payload=_action_payload(), action="proceed") - - assert runtime.calls - dispatched = runtime.calls[-1].message - assert isinstance(dispatched, TimeboxingStageAction) - assert dispatched.action == "proceed" - assert client.updates - assert "Proceeding to the next stage" in (client.updates[0].get("text") or "") - # The stage result arrives as a new message... - assert client.posts, "stage result was not posted as its own message" - assert "CaptureInputs" in (client.posts[-1].get("text") or "") - # ...and the button's message is left as a small receipt, not the artifact. - assert "CaptureInputs" not in (client.updates[-1].get("text") or "") diff --git a/tests/integration/test_timeboxing_durable_constraint_retriever_wiring.py b/tests/integration/test_timeboxing_durable_constraint_retriever_wiring.py deleted file mode 100644 index 3a347582..00000000 --- a/tests/integration/test_timeboxing_durable_constraint_retriever_wiring.py +++ /dev/null @@ -1,58 +0,0 @@ -import pytest - -pytest.importorskip("autogen_agentchat") - -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.constraint_retriever import ConstraintRetriever -from fateforger.agents.timeboxing.stage_gating import TimeboxingStage - - -class _FakeConstraintClient: - def __init__(self) -> None: - self.calls: list[tuple[str, dict]] = [] - - async def query_types(self, *, stage: str | None = None, event_types: list[str] | None = None): - self.calls.append(("query_types", {"stage": stage, "event_types": event_types})) - return [{"type_id": "t1", "count": 10}] - - async def query_constraints( - self, - *, - filters: dict, - type_ids: list[str] | None = None, - tags: list[str] | None = None, - sort: list[list[str]] | None = None, - limit: int = 50, - ): - self.calls.append( - ( - "query_constraints", - { - "filters": filters, - "type_ids": type_ids, - "tags": tags, - "sort": sort, - "limit": limit, - }, - ) - ) - return [] - - -@pytest.mark.asyncio -async def test_fetch_durable_constraints_uses_type_routing() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._constraint_retriever = ConstraintRetriever(max_type_ids=3, query_limit=10) - fake_client = _FakeConstraintClient() - agent._ensure_constraint_memory_client = lambda: fake_client # type: ignore[assignment] - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", planned_date="2026-01-21") - session.frame_facts = {"work_window": {"start": "09:00", "end": "18:00"}, "immovables": []} - session.input_facts = {"block_plan": {"deep_blocks": 1, "shallow_blocks": 0, "block_minutes": 60}} - - out = await agent._fetch_durable_constraints(session, stage=TimeboxingStage.SKELETON) - assert out == [] - assert [c[0] for c in fake_client.calls] == ["query_types", "query_constraints"] - assert fake_client.calls[1][1]["type_ids"] == ["t1"] - assert fake_client.calls[1][1]["filters"]["stage"] == TimeboxingStage.SKELETON.value - diff --git a/tests/unit/constraints/test_constraint_auto_promotion.py b/tests/unit/constraints/test_constraint_auto_promotion.py deleted file mode 100644 index 582497e9..00000000 --- a/tests/unit/constraints/test_constraint_auto_promotion.py +++ /dev/null @@ -1,46 +0,0 @@ -"""session_appearances counter increments on merge; auto-promotes MUST at threshold.""" -from fateforger.agents.timeboxing.agent import ( - AUTO_PROMOTE_THRESHOLD, - _increment_session_appearances, - _should_auto_promote, -) - - -def test_increment_creates_counter_when_absent(): - result = _increment_session_appearances({}) - assert result["session_appearances"] == 1 - - -def test_increment_adds_to_existing_count(): - result = _increment_session_appearances({"session_appearances": 2}) - assert result["session_appearances"] == 3 - - -def test_increment_does_not_mutate_original(): - lifecycle = {"session_appearances": 1} - _increment_session_appearances(lifecycle) - assert lifecycle["session_appearances"] == 1 - - -def test_auto_promote_threshold_is_three(): - assert AUTO_PROMOTE_THRESHOLD == 3 - - -def test_should_auto_promote_at_threshold(): - assert _should_auto_promote(session_appearances=3, necessity="MUST") is True - - -def test_should_auto_promote_above_threshold(): - assert _should_auto_promote(session_appearances=5, necessity="MUST") is True - - -def test_should_not_promote_below_threshold(): - assert _should_auto_promote(session_appearances=2, necessity="MUST") is False - - -def test_should_not_promote_non_must(): - assert _should_auto_promote(session_appearances=5, necessity="SHOULD") is False - - -def test_should_not_promote_prefer(): - assert _should_auto_promote(session_appearances=10, necessity="PREFER") is False diff --git a/tests/unit/constraints/test_constraint_extraction_reason.py b/tests/unit/constraints/test_constraint_extraction_reason.py deleted file mode 100644 index c6d9c93a..00000000 --- a/tests/unit/constraints/test_constraint_extraction_reason.py +++ /dev/null @@ -1,76 +0,0 @@ -# tests/unit/test_constraint_extraction_reason.py -"""Extraction reason must be persisted onto each constraint's hints.""" -from __future__ import annotations - -from types import SimpleNamespace -from typing import Any - -from fateforger.agents.timeboxing.agent import _stamp_extraction_reason -from fateforger.agents.timeboxing.preferences import ( - Constraint, - ConstraintNecessity, -) - - -def _constraint(**hints: Any) -> Constraint: - return Constraint( - name="Dinner", - description="Dinner at 18:30", - necessity=ConstraintNecessity.MUST, - user_id="u1", - hints=dict(hints), - ) - - -def test_stamps_reason_onto_empty_hints() -> None: - c = _constraint() - _stamp_extraction_reason([c], reason="graphflow_turn") - assert c.hints["extraction_reason"] == "graphflow_turn" - - -def test_preserves_existing_hints() -> None: - c = _constraint(uid="abc123") - _stamp_extraction_reason([c], reason="refine_background_memory") - assert c.hints["uid"] == "abc123" - assert c.hints["extraction_reason"] == "refine_background_memory" - - -def test_does_not_overwrite_existing_reason() -> None: - """First extraction wins β€” a later pass must not relabel provenance.""" - c = _constraint() - _stamp_extraction_reason([c], reason="graphflow_turn") - _stamp_extraction_reason([c], reason="refine_background_memory") - assert c.hints["extraction_reason"] == "graphflow_turn" - - -def test_does_not_overwrite_falsy_existing_reason() -> None: - """Presence, not truthiness, guards the write. - - An empty-string placeholder (stale data, or any future writer that stores - one) must still block a later pass from relabelling provenance. - """ - c = _constraint(extraction_reason="") - _stamp_extraction_reason([c], reason="graphflow_turn") - assert c.hints["extraction_reason"] == "" - - -def test_tolerates_empty_list() -> None: - _stamp_extraction_reason([], reason="graphflow_turn") - - -def test_skips_object_without_hints_attribute() -> None: - obj = SimpleNamespace() # no `hints` attribute at all - _stamp_extraction_reason([obj], reason="graphflow_turn") - assert not hasattr(obj, "hints") - - -def test_skips_non_dict_hints() -> None: - obj = SimpleNamespace(hints="not-a-dict") - _stamp_extraction_reason([obj], reason="graphflow_turn") - assert obj.hints == "not-a-dict" - - -def test_tolerates_none_entry_in_iterable() -> None: - c = _constraint() - _stamp_extraction_reason([None, c], reason="graphflow_turn") - assert c.hints["extraction_reason"] == "graphflow_turn" diff --git a/tests/unit/constraints/test_constraint_extractor_tool.py b/tests/unit/constraints/test_constraint_extractor_tool.py deleted file mode 100644 index 3cb81c53..00000000 --- a/tests/unit/constraints/test_constraint_extractor_tool.py +++ /dev/null @@ -1,287 +0,0 @@ -"""The constraint extractor as a tool: its strict signature, that calling it -never blocks the turn, and the background extraction it kicks off. -""" - -from __future__ import annotations - -import pytest -import asyncio -import time -from collections.abc import Awaitable -from typing import Any -import types - - -pytest.importorskip("autogen_agentchat") - - -# ── the strict signature ────────────────────────────────────────────────────── - -from fateforger.agents.timeboxing import agent as timeboxing_agent_mod - - -class _DummyExtractor: - def __init__(self, *, model_client, tools): - self.model_client = model_client - self.tools = tools - - async def extract_and_upsert_constraint(self, **_kwargs): - return None - - -@pytest.mark.asyncio -async def test_extract_and_upsert_constraint_tool_is_strict(monkeypatch): - class _FakeMcpTool: - def __init__(self, *, name: str, payload): - self.name = name - self._payload = payload - - async def run_json(self, _args, _cancellation_token): - return self._payload - - async def _fake_get_constraint_mcp_tools(): - return [ - _FakeMcpTool(name="constraint_query_types", payload=[]), - _FakeMcpTool(name="constraint_query_constraints", payload=[]), - _FakeMcpTool( - name="constraint_upsert_constraint", - payload={"uid": "constraint-1"}, - ), - _FakeMcpTool(name="constraint_log_event", payload={"ok": True}), - ] - - monkeypatch.setattr( - timeboxing_agent_mod, "get_constraint_mcp_tools", _fake_get_constraint_mcp_tools - ) - monkeypatch.setattr( - timeboxing_agent_mod, "NotionConstraintExtractor", _DummyExtractor - ) - monkeypatch.setattr( - timeboxing_agent_mod.settings, "notion_timeboxing_parent_page_id", "dummy", raising=False - ) - - agent = timeboxing_agent_mod.TimeboxingFlowAgent.__new__( - timeboxing_agent_mod.TimeboxingFlowAgent - ) - agent._constraint_mcp_tools = None - agent._notion_extractor = None - agent._constraint_extractor_tool = None - agent._model_client = object() - - await timeboxing_agent_mod.TimeboxingFlowAgent._ensure_constraint_mcp_tools(agent) - - assert agent._constraint_extractor_tool is not None - assert agent._constraint_extractor_tool.schema.get("strict") is True - - -# ── it must not block the turn ──────────────────────────────────────────────── - -from fateforger.agents.timeboxing import agent as timeboxing_agent_mod - - -class _SlowExtractor: - def __init__(self, *, model_client: Any, tools: list[Any]) -> None: - """Test double that simulates a slow extractor call.""" - self.model_client = model_client - self.tools = tools - - async def extract_and_upsert_constraint(self, **_kwargs: Any) -> None: - """Simulate a long-running background upsert.""" - await asyncio.sleep(10) - return None - - -@pytest.mark.asyncio -async def test_extract_and_upsert_constraint_tool_is_nonblocking( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Ensures the durable constraint upsert tool queues work without blocking the stage.""" - created_tasks: list[asyncio.Task] = [] - real_create_task = timeboxing_agent_mod.asyncio.create_task - - def _create_task_and_cancel(coro: Awaitable[Any]) -> asyncio.Task: - """Capture the created task and cancel it to keep the test fast.""" - task = real_create_task(coro) - created_tasks.append(task) - task.cancel() - return task - - class _FakeMcpTool: - def __init__(self, *, name: str, payload: Any) -> None: - self.name = name - self._payload = payload - - async def run_json(self, _args: Any, _cancellation_token: Any) -> Any: - return self._payload - - async def _fake_get_constraint_mcp_tools() -> list[Any]: - return [ - _FakeMcpTool(name="constraint_query_types", payload=[]), - _FakeMcpTool(name="constraint_query_constraints", payload=[]), - _FakeMcpTool( - name="constraint_upsert_constraint", - payload={"uid": "constraint-1"}, - ), - _FakeMcpTool(name="constraint_log_event", payload={"ok": True}), - ] - - monkeypatch.setattr( - timeboxing_agent_mod, "get_constraint_mcp_tools", _fake_get_constraint_mcp_tools - ) - monkeypatch.setattr(timeboxing_agent_mod, "NotionConstraintExtractor", _SlowExtractor) - monkeypatch.setattr(timeboxing_agent_mod.asyncio, "create_task", _create_task_and_cancel) - monkeypatch.setattr( - timeboxing_agent_mod.settings, - "notion_timeboxing_parent_page_id", - "dummy", - raising=False, - ) - - agent = timeboxing_agent_mod.TimeboxingFlowAgent.__new__( - timeboxing_agent_mod.TimeboxingFlowAgent - ) - agent._constraint_mcp_tools = None - agent._notion_extractor = None - agent._constraint_extractor_tool = None - agent._durable_constraint_task_keys = set() - agent._durable_constraint_semaphore = asyncio.Semaphore(1) - agent._model_client = object() - - await timeboxing_agent_mod.TimeboxingFlowAgent._ensure_constraint_mcp_tools(agent) - - tool = agent._constraint_extractor_tool - assert tool is not None - - start = time.monotonic() - result = await asyncio.wait_for( - tool._func( - planned_date="2026-01-21", - timezone="Europe/Amsterdam", - stage_id="CollectConstraints", - user_utterance="In general, I don't do meetings before 10.", - triggering_suggestion="", - impacted_event_types=["M"], - suggested_tags=["work_window"], - decision_scope="", - ), - timeout=0.5, - ) - elapsed = time.monotonic() - start - assert elapsed < 0.5 - assert isinstance(result, dict) - assert result.get("queued") is True - - if created_tasks: - await asyncio.gather(*created_tasks, return_exceptions=True) - - -# ── extraction in the background ────────────────────────────────────────────── - -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.nlu import ConstraintInterpretation -from fateforger.agents.timeboxing.preferences import ( - ConstraintBase, - ConstraintNecessity, - ConstraintScope, -) - - -@pytest.mark.asyncio -async def test_queue_constraint_extraction_runs_in_background(): - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._constraint_extraction_tasks = {} - agent._constraint_extraction_semaphore = asyncio.Semaphore(1) - - async def _fake_interpret(_self, _session, *, text: str, is_initial: bool): - assert text - assert is_initial is False - return ConstraintInterpretation( - should_extract=True, - scope="session", - constraints=[ - ConstraintBase( - name="Deep work mornings", - description="Do deep work in the mornings.", - necessity=ConstraintNecessity.SHOULD, - scope=ConstraintScope.SESSION, - ) - ], - ) - - class _Store: - async def add_constraints(self, **_kwargs): - return [] - - async def _fake_collect_constraints(_session: Session): - return [] - - agent._interpret_constraints = types.MethodType(_fake_interpret, agent) # type: ignore[assignment] - async def _noop_store() -> None: - return None - - agent._ensure_constraint_store = _noop_store # type: ignore[assignment] - agent._constraint_store = _Store() - agent._collect_constraints = _fake_collect_constraints # type: ignore[assignment] - - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - ) - - task = agent._queue_constraint_extraction( - session=session, - text="I do deep work in the mornings.", - reason="test", - is_initial=False, - ) - assert task is not None - res = await asyncio.wait_for(task, timeout=1.0) - assert res is not None - assert not session.pending_constraint_extractions - - -@pytest.mark.asyncio -async def test_queue_constraint_extraction_respects_classifier(): - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._constraint_extraction_tasks = {} - agent._constraint_extraction_semaphore = asyncio.Semaphore(1) - - async def _fake_interpret(_self, _session, *, text: str, is_initial: bool): - assert text - return ConstraintInterpretation( - should_extract=False, - scope="session", - constraints=[], - ) - - class _Store: - async def add_constraints(self, **_kwargs): - return [] - - async def _fake_collect_constraints(_session: Session): - return None - - agent._interpret_constraints = types.MethodType(_fake_interpret, agent) # type: ignore[assignment] - async def _noop_store() -> None: - return None - - agent._ensure_constraint_store = _noop_store # type: ignore[assignment] - agent._constraint_store = _Store() - agent._collect_constraints = _fake_collect_constraints # type: ignore[assignment] - - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - ) - - task = agent._queue_constraint_extraction( - session=session, - text="Start timeboxing", - reason="test", - is_initial=True, - ) - assert task is not None - res = await asyncio.wait_for(task, timeout=1.0) - assert res is None diff --git a/tests/unit/constraints/test_constraint_frame_slot_normalisation.py b/tests/unit/constraints/test_constraint_frame_slot_normalisation.py deleted file mode 100644 index 78468002..00000000 --- a/tests/unit/constraints/test_constraint_frame_slot_normalisation.py +++ /dev/null @@ -1,28 +0,0 @@ -"""frame_slot aliases must be normalised to canonical slugs on load.""" -from fateforger.agents.timeboxing.agent import _normalise_frame_slot - - -def test_evening_ritual_maps_to_evening_wind_down(): - assert _normalise_frame_slot("evening_ritual") == "evening_wind_down" - - -def test_pre_sleep_prep_maps_to_shutdown(): - assert _normalise_frame_slot("pre_sleep_prep") == "shutdown" - - -def test_canonical_value_unchanged(): - assert _normalise_frame_slot("morning_ritual") == "morning_ritual" - assert _normalise_frame_slot("sleep_target") == "sleep_target" - assert _normalise_frame_slot("dinner") == "dinner" - - -def test_none_returns_none(): - assert _normalise_frame_slot(None) is None - - -def test_empty_returns_none(): - assert _normalise_frame_slot("") is None - - -def test_unknown_slug_returned_as_is(): - assert _normalise_frame_slot("saxophone_practice") == "saxophone_practice" diff --git a/tests/unit/constraints/test_constraint_memory_component.py b/tests/unit/constraints/test_constraint_memory_component.py deleted file mode 100644 index ba20a71d..00000000 --- a/tests/unit/constraints/test_constraint_memory_component.py +++ /dev/null @@ -1,55 +0,0 @@ -from __future__ import annotations - -import json - -from autogen_core.memory import MemoryContent -from autogen_core.model_context import BufferedChatCompletionContext - -from fateforger.agents.timeboxing.constraint_memory_component import ConstraintPlanningMemory - - -class _FakeStore: - def __init__(self) -> None: - self.rows = [ - {"uid": "tb1", "name": "Deep Work", "description": "Morning block", "status": "locked"} - ] - self.upserts: list[dict] = [] - - async def query_constraints(self, *, filters, type_ids=None, tags=None, sort=None, limit=50): - _ = (filters, type_ids, tags, sort, limit) - return list(self.rows) - - async def upsert_constraint(self, *, record, event=None): - self.upserts.append({"record": record, "event": event}) - return {"uid": "tb_new"} - - -async def test_constraint_planning_memory_updates_context() -> None: - store = _FakeStore() - memory = ConstraintPlanningMemory(store_provider=lambda: store, max_items=5) - memory.set_planning_state({"stage": "Refine", "planned_date": "2026-02-26"}) - - context = BufferedChatCompletionContext(buffer_size=10) - result = await memory.update_context(context) - messages = await context.get_messages() - - assert result.memories.results - assert messages - assert "Relevant durable constraints" in str(messages[-1].content) - - -async def test_constraint_planning_memory_query_and_add() -> None: - store = _FakeStore() - memory = ConstraintPlanningMemory(store_provider=lambda: store, max_items=5) - - queried = await memory.query("deep work") - assert queried.results - - await memory.add( - MemoryContent( - content=json.dumps({"constraint_record": {"name": "New Rule"}}), - mime_type="application/json", - metadata={}, - ) - ) - assert store.upserts diff --git a/tests/unit/constraints/test_constraint_nlu_frame_slot.py b/tests/unit/constraints/test_constraint_nlu_frame_slot.py deleted file mode 100644 index 1bee5cfd..00000000 --- a/tests/unit/constraints/test_constraint_nlu_frame_slot.py +++ /dev/null @@ -1,54 +0,0 @@ -"""What the frame_slot instruction must and must not do. - -This file used to assert that a fourteen-slug vocabulary was hardcoded, and -that the prompt repeated it. Both tests passed for as long as the list existed -and said nothing about whether it was right. - -It was not. Measured against the anchors the memory server had learned from the -user's own words: ten of the fourteen named things he has never said -(`dog_walk`, `music_making`, `pre_gym_meal`, `sleep_target`, `work_window`), -while fourteen things he does do were missing (`fika`, `market_visits`, -`nature_reservation`, `prep_food`, `admin`, `finance`). A hand-typed model of -somebody's life is wrong in both directions at once. - -What is worth asserting is the shape of the instruction, not its contents. -""" - -from fateforger.agents.timeboxing import nlu -from fateforger.agents.timeboxing.nlu import CONSTRAINT_INTERPRETER_PROMPT - - -def test_no_frozen_slot_vocabulary_comes_back() -> None: - """A slot is an anchor, and an anchor is discovered, not declared.""" - - assert not hasattr(nlu, "FRAME_SLOT_CANONICAL_VALUES") - - -def test_the_prompt_names_no_closed_list_of_habits() -> None: - """Catches somebody's daily routine reappearing as an enumeration. - - Illustrations are fine and useful -- the prompt still shows the *shape* of a - slug. What must not return is a list presented as the set of slots that - exist, because the model then reaches for the nearest member instead of - naming what the user actually said. - """ - - assert "Canonical values" not in CONSTRAINT_INTERPRETER_PROMPT - assert "pre_gym_meal" not in CONSTRAINT_INTERPRETER_PROMPT - - -def test_the_vocabulary_is_the_users_own() -> None: - """Asserted on a fragment, because the prompt is hard-wrapped.""" - - assert "own habits" in CONSTRAINT_INTERPRETER_PROMPT - assert "not a fixed list" in CONSTRAINT_INTERPRETER_PROMPT - - -def test_a_recurring_routine_still_gets_a_slot() -> None: - """The one rule that survives: a routine without a slot anchors nothing.""" - - assert "null" in CONSTRAINT_INTERPRETER_PROMPT - assert ( - "recurring" in CONSTRAINT_INTERPRETER_PROMPT - or "routine" in CONSTRAINT_INTERPRETER_PROMPT - ) diff --git a/tests/unit/constraints/test_constraint_reconciliation.py b/tests/unit/constraints/test_constraint_reconciliation.py deleted file mode 100644 index b3ec8e24..00000000 --- a/tests/unit/constraints/test_constraint_reconciliation.py +++ /dev/null @@ -1,90 +0,0 @@ -from __future__ import annotations - -from datetime import date - -from fateforger.agents.timeboxing.constraint_reconciliation import ( - reconcile_constraint_rows, -) - - -def _row( - *, - uid: str, - status: str = "proposed", - updated_at: str = "2026-03-06T09:00:00+00:00", - start_date: str | None = None, - end_date: str | None = None, - days_of_week: list[str] | None = None, - applies_stages: list[str] | None = None, -) -> dict[str, object]: - return { - "uid": uid, - "name": "office_commute_home", - "description": "Commute office to home", - "necessity": "must", - "status": status, - "scope": "profile", - "source": "user", - "rule_kind": "commute", - "topics": ["commute", "office"], - "start_date": start_date, - "end_date": end_date, - "days_of_week": list(days_of_week or []), - "applies_stages": list(applies_stages or []), - "updated_at": updated_at, - } - - -def test_reconcile_rows_keeps_strongest_canonical_candidate() -> None: - rows = [ - _row(uid="legacy-1", status="proposed", updated_at="2026-03-05T09:00:00+00:00"), - _row(uid="new-1", status="locked", updated_at="2026-03-06T09:00:00+00:00"), - ] - - result = reconcile_constraint_rows( - rows=rows, - planned_day=date(2026, 3, 6), - stage="refine", - ) - - assert result.raw_count == 2 - assert result.canonical_count == 1 - assert result.applicable_count == 1 - assert result.applicable_rows[0]["uid"] == "new-1" - assert result.duplicate_groups == [ - {"canonical_uid": "new-1", "duplicate_uids": ["legacy-1"]} - ] - - -def test_reconcile_rows_filters_out_non_applicable_day_and_stage() -> None: - rows = [ - _row( - uid="weekend-only", - days_of_week=["SA", "SU"], - applies_stages=["skeleton"], - ) - ] - - result = reconcile_constraint_rows( - rows=rows, - planned_day=date(2026, 3, 6), # Friday - stage="refine", - ) - - assert result.raw_count == 1 - assert result.canonical_count == 1 - assert result.applicable_count == 0 - - -def test_reconcile_rows_drops_declined_constraints() -> None: - rows = [_row(uid="declined-1", status="declined")] - - result = reconcile_constraint_rows( - rows=rows, - planned_day=date(2026, 3, 6), - stage="collect_constraints", - ) - - assert result.raw_count == 1 - assert result.canonical_count == 1 - assert result.applicable_count == 0 diff --git a/tests/unit/constraints/test_constraint_relevance_filter.py b/tests/unit/constraints/test_constraint_relevance_filter.py deleted file mode 100644 index b9938351..00000000 --- a/tests/unit/constraints/test_constraint_relevance_filter.py +++ /dev/null @@ -1,316 +0,0 @@ -"""Tests for _is_stage_relevant_constraint β€” Bug A: aspect-classified constraints -should only be included when their aspect_id is present in the session.""" - -from fateforger.agents.timeboxing.agent import TimeboxingFlowAgent -from fateforger.agents.timeboxing.preferences import ( - Constraint, - ConstraintNecessity, - ConstraintScope, - ConstraintStatus, -) -from fateforger.agents.timeboxing.stage_gating import TimeboxingStage - -_fn = TimeboxingFlowAgent._is_stage_relevant_constraint - - -def _make_aspect_constraint( - aspect_id: str, - *, - frame_slot: str | None = None, - schedule_start: str | None = None, - schedule_end: str | None = None, - is_startup_prefetch: bool = False, - scope: ConstraintScope = ConstraintScope.PROFILE, - status: ConstraintStatus = ConstraintStatus.LOCKED, - necessity: ConstraintNecessity = ConstraintNecessity.MUST, -) -> Constraint: - aspect_cls = { - "aspect_id": aspect_id, - "frame_slot": frame_slot, - "schedule_start": schedule_start, - "schedule_end": schedule_end, - "is_startup_prefetch": is_startup_prefetch, - } - return Constraint( - name=f"Test constraint ({aspect_id})", - scope=scope, - status=status, - necessity=necessity, - hints={"aspect_classification": aspect_cls}, - ) - - -def _make_plain_constraint( - name: str = "Plain constraint", - *, - scope: ConstraintScope = ConstraintScope.PROFILE, - status: ConstraintStatus = ConstraintStatus.LOCKED, - necessity: ConstraintNecessity = ConstraintNecessity.MUST, - hints: dict | None = None, -) -> Constraint: - return Constraint( - name=name, - scope=scope, - status=status, - necessity=necessity, - hints=hints or {}, - ) - - -# ── Bug A tests ────────────────────────────────────────────────────────────── - -class TestAspectClassifiedNotInSession: - """Bug A: aspect-classified constraints with no session match must be excluded.""" - - def test_market_visit_excluded_when_not_in_session_collect(self): - """'Market opening hours' (aspect_id=market_visit) must not appear at - CollectConstraints when no market visit is in the session.""" - constraint = _make_aspect_constraint( - "market_visit", schedule_start="08:00", schedule_end="16:00" - ) - result = _fn( - constraint=constraint, - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - session_aspect_ids=set(), - ) - assert result is False - - def test_market_visit_excluded_when_not_in_session_skeleton(self): - constraint = _make_aspect_constraint( - "market_visit", schedule_start="08:00", schedule_end="16:00" - ) - result = _fn( - constraint=constraint, - stage=TimeboxingStage.SKELETON, - session_aspect_ids=set(), - ) - assert result is False - - def test_market_visit_excluded_when_not_in_session_capture_inputs(self): - constraint = _make_aspect_constraint( - "market_visit", schedule_start="08:00", schedule_end="16:00" - ) - result = _fn( - constraint=constraint, - stage=TimeboxingStage.CAPTURE_INPUTS, - session_aspect_ids=set(), - ) - assert result is False - - def test_market_visit_excluded_when_not_in_session_refine(self): - constraint = _make_aspect_constraint( - "market_visit", schedule_start="08:00", schedule_end="16:00" - ) - result = _fn( - constraint=constraint, - stage=TimeboxingStage.REFINE, - session_aspect_ids=set(), - ) - assert result is False - - -class TestAspectClassifiedInSession: - """Aspect-classified constraints ARE included when their aspect_id is present.""" - - def test_market_visit_included_when_in_session(self): - constraint = _make_aspect_constraint( - "market_visit", schedule_start="08:00", schedule_end="16:00" - ) - result = _fn( - constraint=constraint, - stage=TimeboxingStage.SKELETON, - session_aspect_ids={"market_visit"}, - ) - assert result is True - - def test_market_visit_included_at_collect_when_in_session(self): - constraint = _make_aspect_constraint( - "market_visit", schedule_start="08:00", schedule_end="16:00" - ) - result = _fn( - constraint=constraint, - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - session_aspect_ids={"market_visit"}, - ) - assert result is True - - -class TestFrameSlotAlwaysIncluded: - """Constraints with a frame_slot (fixed daily routines) are always included.""" - - def test_frame_slot_included_without_session_match(self): - constraint = _make_aspect_constraint("morning_ritual", frame_slot="morning") - result = _fn( - constraint=constraint, - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - session_aspect_ids=set(), - ) - assert result is True - - def test_frame_slot_included_at_skeleton(self): - constraint = _make_aspect_constraint("gym", frame_slot="evening") - result = _fn( - constraint=constraint, - stage=TimeboxingStage.SKELETON, - session_aspect_ids=set(), - ) - assert result is True - - -class TestStartupPrefetchAlwaysIncluded: - """is_startup_prefetch=True constraints always included.""" - - def test_startup_prefetch_included_without_session_match(self): - constraint = _make_aspect_constraint( - "sleep_window", is_startup_prefetch=True, schedule_start="23:30" - ) - result = _fn( - constraint=constraint, - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - session_aspect_ids=set(), - ) - assert result is True - - -class TestSessionScopedAlwaysIncluded: - """SESSION-scoped constraints (extracted from user input / calendar) always included.""" - - def test_session_scoped_always_included(self): - constraint = _make_aspect_constraint( - "gym", - scope=ConstraintScope.SESSION, - status=ConstraintStatus.PROPOSED, - ) - result = _fn( - constraint=constraint, - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - session_aspect_ids=set(), - ) - assert result is True - - -class TestFrameSlotAnchorFilter: - """Any non-null frame_slot must be treated as a startup anchor, not just sleep/work.""" - - def test_dinner_frame_slot_passes_filter(self): - constraint = _make_aspect_constraint("dinner_slot", frame_slot="dinner") - result = _fn( - constraint=constraint, - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - session_aspect_ids=set(), - ) - assert result is True - - def test_morning_ritual_frame_slot_passes_filter(self): - constraint = _make_aspect_constraint("morning_routine", frame_slot="morning_ritual") - result = _fn( - constraint=constraint, - stage=TimeboxingStage.SKELETON, - session_aspect_ids=set(), - ) - assert result is True - - def test_custom_slug_frame_slot_passes_filter(self): - constraint = _make_aspect_constraint("sax_slot", frame_slot="saxophone_practice") - result = _fn( - constraint=constraint, - stage=TimeboxingStage.SKELETON, - session_aspect_ids=set(), - ) - assert result is True - - -class TestMustLockedNonAspectConstraints: - """MUST+LOCKED profile constraints without aspect_classification still pass (e.g. Commute Duration).""" - - def test_commute_duration_always_included(self): - constraint = _make_plain_constraint( - "Commute Duration", - necessity=ConstraintNecessity.MUST, - status=ConstraintStatus.LOCKED, - ) - result = _fn( - constraint=constraint, - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - session_aspect_ids=set(), - ) - assert result is True - - def test_oats_timing_always_included(self): - constraint = _make_plain_constraint( - "Oats Timing", - necessity=ConstraintNecessity.MUST, - status=ConstraintStatus.LOCKED, - ) - result = _fn( - constraint=constraint, - stage=TimeboxingStage.SKELETON, - session_aspect_ids=set(), - ) - assert result is True - - -class TestProfileProposedMustIncluded: - """PROFILE/PROPOSED/MUST constraints (user lifestyle preferences) must be loaded. - - Bug: The MUST+LOCKED gate was too strict β€” PROPOSED constraints were silently - dropped even though they represent the user's stated durable preferences. - """ - - def test_evening_shutdown_ritual_included(self): - """Evening Shutdown Ritual is PROFILE/PROPOSED/MUST β€” must be loaded.""" - constraint = _make_plain_constraint( - "Evening Shutdown Ritual", - scope=ConstraintScope.PROFILE, - status=ConstraintStatus.PROPOSED, - necessity=ConstraintNecessity.MUST, - ) - result = _fn( - constraint=constraint, - stage=TimeboxingStage.SKELETON, - session_aspect_ids=set(), - ) - assert result is True - - def test_dinner_included(self): - constraint = _make_plain_constraint( - "Dinner", - scope=ConstraintScope.PROFILE, - status=ConstraintStatus.PROPOSED, - necessity=ConstraintNecessity.MUST, - ) - result = _fn( - constraint=constraint, - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - session_aspect_ids=set(), - ) - assert result is True - - def test_shutdown_ritual_included_at_refine(self): - constraint = _make_plain_constraint( - "Shutdown Ritual", - scope=ConstraintScope.PROFILE, - status=ConstraintStatus.PROPOSED, - necessity=ConstraintNecessity.MUST, - ) - result = _fn( - constraint=constraint, - stage=TimeboxingStage.REFINE, - session_aspect_ids=set(), - ) - assert result is True - - def test_profile_proposed_should_still_excluded(self): - """PROFILE/PROPOSED/SHOULD (lower-priority preferences) are NOT loaded β€” to avoid noise.""" - constraint = _make_plain_constraint( - "Sci-fi Reading", - scope=ConstraintScope.PROFILE, - status=ConstraintStatus.PROPOSED, - necessity=ConstraintNecessity.SHOULD, - ) - result = _fn( - constraint=constraint, - stage=TimeboxingStage.SKELETON, - session_aspect_ids=set(), - ) - assert result is False diff --git a/tests/unit/constraints/test_constraint_retriever.py b/tests/unit/constraints/test_constraint_retriever.py deleted file mode 100644 index cf55ceb0..00000000 --- a/tests/unit/constraints/test_constraint_retriever.py +++ /dev/null @@ -1,71 +0,0 @@ -import pytest - -pytest.importorskip("autogen_agentchat") - -from datetime import date - -from fateforger.agents.timeboxing.constraint_retriever import ConstraintRetriever -from fateforger.agents.timeboxing.contracts import BlockPlan, Immovable, SleepTarget, WorkWindow -from fateforger.agents.timeboxing.stage_gating import TimeboxingStage - - -class _FakeConstraintClient: - def __init__(self) -> None: - self.calls: list[tuple[str, object]] = [] - - async def query_types(self, *, stage: str | None = None, event_types: list[str] | None = None): - self.calls.append(("query_types", {"stage": stage, "event_types": event_types})) - return [{"type_id": f"type_{i}", "count": 100 - i} for i in range(50)] - - async def query_constraints( - self, - *, - filters: dict, - type_ids: list[str] | None = None, - tags: list[str] | None = None, - sort: list[list[str]] | None = None, - limit: int = 50, - ): - self.calls.append( - ( - "query_constraints", - { - "filters": filters, - "type_ids": type_ids, - "tags": tags, - "sort": sort, - "limit": limit, - }, - ) - ) - return [{"uid": "u1", "name": "c1", "description": "d1"}] - - -@pytest.mark.asyncio -async def test_retriever_builds_type_id_narrowed_query(): - retriever = ConstraintRetriever(max_type_ids=5, query_limit=20) - client = _FakeConstraintClient() - - plan, records = await retriever.retrieve( - client=client, # type: ignore[arg-type] - stage=TimeboxingStage.SKELETON, - planned_day=date(2026, 1, 21), - work_window=WorkWindow(start="09:00", end="18:00"), - sleep_target=SleepTarget(start=None, end=None, hours=None), - immovables=[Immovable(title="Meeting", start="10:00", end="10:30")], - block_plan=BlockPlan(deep_blocks=2, shallow_blocks=1, block_minutes=60, focus_theme=None), - frame_facts={"commutes": [{"label": "Office", "duration_min": 30}]}, - ) - - assert plan.stage == TimeboxingStage.SKELETON - assert plan.limit == 20 - assert len(plan.type_ids) == 5 - assert records and records[0]["uid"] == "u1" - - assert client.calls[0][0] == "query_types" - assert client.calls[1][0] == "query_constraints" - qc = client.calls[1][1] - assert qc["filters"]["stage"] == TimeboxingStage.SKELETON.value - assert qc["limit"] == 20 - assert qc["type_ids"] == plan.type_ids - diff --git a/tests/unit/constraints/test_constraint_search_tool.py b/tests/unit/constraints/test_constraint_search_tool.py deleted file mode 100644 index 057b2942..00000000 --- a/tests/unit/constraints/test_constraint_search_tool.py +++ /dev/null @@ -1,372 +0,0 @@ -"""Tests for the constraint search tool module.""" - -from __future__ import annotations - -import asyncio -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from fateforger.agents.timeboxing.constraint_search_tool import ( - ConstraintSearchPlan, - ConstraintSearchQuery, - ConstraintSearchResponse, - ConstraintSearchResult, - _dedupe_results, - _raw_to_result, - execute_search_plan, - format_constraint_oneliner, - format_search_summary, - search_constraints, -) - - -# --------------------------------------------------------------------------- -# Fixtures -# --------------------------------------------------------------------------- - - -def _make_raw_constraint( - uid: str = "tb:test:abc123", - name: str = "Deep Work Preference", - description: str = "Prefer 2 deep work blocks in the morning", - necessity: str = "should", - status: str = "locked", - scope: str = "profile", - rule_kind: str = "prefer_window", - days_of_week: list[str] | None = None, - topics: list[str] | None = None, - **kwargs: Any, -) -> dict[str, Any]: - """Create a raw constraint dict matching MCP server output format.""" - return { - "page_id": kwargs.get("page_id", "page-001"), - "uid": uid, - "name": name, - "description": description, - "necessity": necessity, - "status": status, - "scope": scope, - "rule_kind": rule_kind, - "type_id": kwargs.get("type_id"), - "days_of_week": days_of_week or [], - "start_date": kwargs.get("start_date"), - "end_date": kwargs.get("end_date"), - "topics": topics or [], - "url": kwargs.get("url"), - "source": kwargs.get("source", "user"), - } - - -def _make_mock_client(results: list[list[dict[str, Any]]]) -> MagicMock: - """Create a mock ConstraintMemoryClient that returns sequential results.""" - client = MagicMock() - call_count = 0 - - async def query_constraints(**kwargs: Any) -> list[dict[str, Any]]: - nonlocal call_count - idx = min(call_count, len(results) - 1) - call_count += 1 - return results[idx] - - client.query_constraints = AsyncMock(side_effect=query_constraints) - return client - - -def _make_query(**overrides: Any) -> ConstraintSearchQuery: - payload: dict[str, Any] = { - "label": "q", - "text_query": None, - "event_types": None, - "tags": None, - "statuses": None, - "scopes": None, - "necessities": None, - "limit": 20, - } - payload.update(overrides) - return ConstraintSearchQuery(**payload) - - -# --------------------------------------------------------------------------- -# Unit tests: _raw_to_result -# --------------------------------------------------------------------------- - - -class TestRawToResult: - """Tests for converting raw MCP dicts to typed results.""" - - def test_basic_conversion(self) -> None: - raw = _make_raw_constraint() - result = _raw_to_result(raw) - assert isinstance(result, ConstraintSearchResult) - assert result.uid == "tb:test:abc123" - assert result.name == "Deep Work Preference" - assert result.necessity == "should" - assert result.status == "locked" - assert result.scope == "profile" - - def test_missing_fields_default_gracefully(self) -> None: - result = _raw_to_result({"page_id": "p1"}) - assert result.uid is None - assert result.name is None - assert result.days_of_week == [] - assert result.topics == [] - - -# --------------------------------------------------------------------------- -# Unit tests: deduplication -# --------------------------------------------------------------------------- - - -class TestDedupeResults: - """Tests for result deduplication.""" - - def test_dedupes_by_uid(self) -> None: - r1 = ConstraintSearchResult(uid="a", name="Constraint A") - r2 = ConstraintSearchResult(uid="a", name="Constraint A (dup)") - r3 = ConstraintSearchResult(uid="b", name="Constraint B") - result = _dedupe_results([r1, r2, r3]) - assert len(result) == 2 - assert result[0].name == "Constraint A" - assert result[1].name == "Constraint B" - - def test_dedupes_by_page_id_fallback(self) -> None: - r1 = ConstraintSearchResult(page_id="p1", name="X") - r2 = ConstraintSearchResult(page_id="p1", name="Y") - result = _dedupe_results([r1, r2]) - assert len(result) == 1 - - def test_preserves_order(self) -> None: - items = [ - ConstraintSearchResult(uid=f"u{i}", name=f"C{i}") for i in range(5) - ] - result = _dedupe_results(items) - assert [r.uid for r in result] == ["u0", "u1", "u2", "u3", "u4"] - - -# --------------------------------------------------------------------------- -# Unit tests: formatting -# --------------------------------------------------------------------------- - - -class TestFormatting: - """Tests for constraint summary formatting.""" - - def test_oneliner_full(self) -> None: - c = ConstraintSearchResult( - uid="test", - name="Morning Focus", - description="Deep work sessions in the morning hours", - necessity="must", - status="locked", - scope="profile", - rule_kind="prefer_window", - days_of_week=["MO", "TU", "WE"], - topics=["focus", "productivity"], - ) - line = format_constraint_oneliner(c) - assert "[locked | must]" in line - assert "Morning Focus" in line - assert "Deep work sessions" in line - assert "scope=profile" in line - assert "kind=prefer_window" in line - assert "days=MO,TU,WE" in line - - def test_oneliner_minimal(self) -> None: - c = ConstraintSearchResult(name="Simple rule") - line = format_constraint_oneliner(c) - assert "Simple rule" in line - - def test_oneliner_unnamed(self) -> None: - c = ConstraintSearchResult() - line = format_constraint_oneliner(c) - assert "(unnamed)" in line - - def test_summary_empty(self) -> None: - assert "No constraints found" in format_search_summary([]) - - def test_summary_numbered(self) -> None: - items = [ - ConstraintSearchResult(name=f"Rule {i}") for i in range(3) - ] - summary = format_search_summary(items) - assert "1. " in summary - assert "2. " in summary - assert "3. " in summary - assert "Rule 0" in summary - assert "Rule 2" in summary - - -# --------------------------------------------------------------------------- -# Unit tests: search plan execution -# --------------------------------------------------------------------------- - - -class TestExecuteSearchPlan: - """Tests for parallel search execution.""" - - @pytest.mark.asyncio - async def test_single_query(self) -> None: - raw = [_make_raw_constraint(uid="u1", name="Test Rule")] - client = _make_mock_client([raw]) - plan = ConstraintSearchPlan( - queries=[_make_query(label="test", text_query="deep work")], - planned_date="2025-01-15", - ) - response = await execute_search_plan(client, plan) - assert response.total_found == 1 - assert response.queries_executed == 1 - assert "Test Rule" in response.summary - - @pytest.mark.asyncio - async def test_multiple_queries_deduped(self) -> None: - # Both queries return the same constraint β€” should be deduped. - raw = [_make_raw_constraint(uid="u1", name="Shared")] - client = _make_mock_client([raw, raw]) - plan = ConstraintSearchPlan( - queries=[ - _make_query(label="q1", text_query="Shared"), - _make_query(label="q2", event_types=["DW"]), - ], - ) - response = await execute_search_plan(client, plan) - assert response.total_found == 1 - assert response.queries_executed == 2 - - @pytest.mark.asyncio - async def test_multiple_queries_distinct(self) -> None: - raw_a = [_make_raw_constraint(uid="u1", name="A")] - raw_b = [_make_raw_constraint(uid="u2", name="B")] - client = _make_mock_client([raw_a, raw_b]) - plan = ConstraintSearchPlan( - queries=[ - _make_query(label="q1", text_query="A"), - _make_query(label="q2", text_query="B"), - ], - ) - response = await execute_search_plan(client, plan) - assert response.total_found == 2 - - @pytest.mark.asyncio - async def test_empty_results(self) -> None: - client = _make_mock_client([[]]) - plan = ConstraintSearchPlan( - queries=[_make_query(label="empty", text_query="nonexistent")], - ) - response = await execute_search_plan(client, plan) - assert response.total_found == 0 - assert "No constraints found" in response.summary - - @pytest.mark.asyncio - async def test_query_exception_handled(self) -> None: - client = MagicMock() - client.query_constraints = AsyncMock(side_effect=RuntimeError("MCP down")) - plan = ConstraintSearchPlan( - queries=[_make_query(label="fail", text_query="test")], - ) - response = await execute_search_plan(client, plan) - assert response.total_found == 0 - assert response.queries_executed == 1 - assert response.errors - assert "fail:" in response.errors[0] - - -# --------------------------------------------------------------------------- -# Unit tests: FunctionTool wrapper -# --------------------------------------------------------------------------- - - -class TestSearchConstraintsWrapper: - """Tests for the FunctionTool-compatible wrapper function.""" - - @pytest.mark.asyncio - async def test_no_client_returns_error(self) -> None: - result = await search_constraints( - queries=[{"label": "test", "text_query": "anything"}], - _client=None, - ) - assert "Error" in result - assert "not available" in result - - @pytest.mark.asyncio - async def test_with_client_returns_summary(self) -> None: - raw = [_make_raw_constraint(uid="u1", name="Found It")] - client = _make_mock_client([raw]) - result = await search_constraints( - queries=[{"label": "test", "text_query": "deep work"}], - planned_date="2025-01-15", - stage="Skeleton", - _client=client, - ) - assert "Found It" in result - assert "1 constraint(s)" in result - - @pytest.mark.asyncio - async def test_wrapper_surfaces_errors_section(self) -> None: - client = MagicMock() - client.query_constraints = AsyncMock(side_effect=RuntimeError("MCP down")) - result = await search_constraints( - queries=[{"label": "fail", "text_query": "x"}], - stage="Skeleton", - _client=client, - ) - assert "ERRORS (" in result - assert "fail:" in result - - @pytest.mark.asyncio - async def test_passes_filters_correctly(self) -> None: - client = MagicMock() - client.query_constraints = AsyncMock(return_value=[]) - await search_constraints( - queries=[{ - "label": "scoped", - "text_query": "focus", - "event_types": ["DW"], - "statuses": ["locked"], - "scopes": ["profile"], - "necessities": ["must"], - "tags": ["work"], - }], - planned_date="2025-06-01", - stage="Skeleton", - _client=client, - ) - call_kwargs = client.query_constraints.call_args - filters = call_kwargs.kwargs["filters"] - assert filters["text_query"] == "focus" - assert filters["event_types_any"] == ["DW"] - assert filters["statuses_any"] == ["locked"] - assert filters["scopes_any"] == ["profile"] - assert filters["necessities_any"] == ["must"] - assert call_kwargs.kwargs["tags"] == ["work"] - assert filters["as_of"] == "2025-06-01" - assert filters["stage"] == "Skeleton" - - -# --------------------------------------------------------------------------- -# Unit tests: ConstraintSearchPlan validation -# --------------------------------------------------------------------------- - - -class TestSearchPlanValidation: - """Tests for Pydantic model validation.""" - - def test_min_one_query(self) -> None: - with pytest.raises(Exception): - ConstraintSearchPlan(queries=[]) - - def test_max_eight_queries(self) -> None: - queries = [_make_query(label=f"q{i}") for i in range(9)] - with pytest.raises(Exception): - ConstraintSearchPlan(queries=queries) - - def test_valid_plan(self) -> None: - plan = ConstraintSearchPlan( - queries=[_make_query(label="q1")], - planned_date="2025-01-15", - stage="Skeleton", - ) - assert len(plan.queries) == 1 - assert plan.stage == "Skeleton" diff --git a/tests/unit/constraints/test_kg_constraint_client.py b/tests/unit/constraints/test_kg_constraint_client.py index e33bca1d..da69f393 100644 --- a/tests/unit/constraints/test_kg_constraint_client.py +++ b/tests/unit/constraints/test_kg_constraint_client.py @@ -165,29 +165,23 @@ async def test_it_satisfies_the_contract_the_agent_adapts(tmp_path): assert rows and rows[0]["name"] == "Work start time" -def test_the_rows_survive_the_reconciliation_the_agent_runs(tmp_path): - """The real integration risk: rows shaped wrongly are dropped in silence. - - `reconcile_constraint_rows` is what the agent puts the prefetch through, and - a row it cannot read disappears without an error -- the same failure mode - the Notion backend had, arriving from a different direction. - """ +def test_the_rows_survive_the_reader_the_harness_runs(tmp_path): + """Rows shaped wrongly are dropped in silence -- the failure mode the + Notion backend had. The harness reads through the durable store adapter, + so that is the reader that must see every row.""" import asyncio - from fateforger.agents.timeboxing.constraint_reconciliation import ( - reconcile_constraint_rows, + from fateforger.agents.timeboxing.durable_constraint_store import ( + build_durable_constraint_store, ) db = _store_with(tmp_path, _constraint(), _constraint(name="Commute duration")) - rows = asyncio.run(KGConstraintMemoryClient(db).query_constraints(filters={})) + store = build_durable_constraint_store(KGConstraintMemoryClient(db)) + rows = asyncio.run(store.query_constraints(filters={}, limit=50)) - result = reconcile_constraint_rows( - rows=rows, planned_day=date(2026, 8, 24), stage="Refine" + assert sorted(r["name"] for r in rows) == sorted( + [_constraint().name, "Commute duration"] ) - assert result.raw_count == 2 - assert result.canonical_count == 2 - # The one that matters: they must still be applicable after reconciliation. - assert result.applicable_count == 2 # -- anchors and suspension ------------------------------------------------- diff --git a/tests/unit/constraints/test_slack_constraint_review.py b/tests/unit/constraints/test_slack_constraint_review.py deleted file mode 100644 index 0d2e963d..00000000 --- a/tests/unit/constraints/test_slack_constraint_review.py +++ /dev/null @@ -1,117 +0,0 @@ -from types import SimpleNamespace - -from fateforger.adapters.notion.timeboxing_preferences import CStatus, Necessity, Scope -from fateforger.agents.timeboxing.preferences import ConstraintScope, ConstraintStatus -from fateforger.slack_bot.constraint_review import ( - CONSTRAINT_DECISION_ACTION_ID, - CONSTRAINT_DESCRIPTION_ACTION_ID, - build_constraint_review_view, - build_constraint_row_blocks, - decode_metadata, - parse_constraint_review_submission, -) - - -def test_constraint_review_view_accepts_uno_like_object(): - constraint = SimpleNamespace( - uid="uid_1", - name=" Sleep window ", - description="Keep 8h sleep", - necessity=Necessity.MUST, - status=CStatus.LOCKED, - scope=Scope.PROFILE, - ) - view = build_constraint_review_view( - constraint, - channel_id="C1", - thread_ts="123.456", - user_id="U1", - ) - assert view["type"] == "modal" - assert view["title"]["text"] == "Constraint review" - header = view["blocks"][0]["text"]["text"] - assert "*Sleep window*" in header - assert "(must)" in header - assert "Scope: profile" in header - - -def test_constraint_review_view_sets_decline_initial_option(): - constraint = SimpleNamespace( - id=42, - name="No late meetings", - description="", - necessity="should", - status=ConstraintStatus.DECLINED, - scope=ConstraintScope.SESSION, - ) - view = build_constraint_review_view( - constraint, - channel_id="C1", - thread_ts="123.456", - user_id="U1", - ) - decision_block = next( - block for block in view["blocks"] if block.get("block_id") == "constraint_decision_block" - ) - element = decision_block["element"] - assert element["action_id"] == CONSTRAINT_DECISION_ACTION_ID - assert element["initial_option"]["value"] == "decline" - - -def test_constraint_review_metadata_round_trip_includes_constraint_id(): - constraint = SimpleNamespace( - id=123, - name="Constraint", - description="", - necessity="must", - status=ConstraintStatus.PROPOSED, - scope=ConstraintScope.SESSION, - ) - view = build_constraint_review_view( - constraint, - channel_id="C123", - thread_ts="111.222", - user_id="U123", - ) - metadata = decode_metadata(view["private_metadata"]) - assert metadata["constraint_id"] == "123" - assert metadata["channel_id"] == "C123" - assert metadata["thread_ts"] == "111.222" - assert metadata["user_id"] == "U123" - - -def test_constraint_row_blocks_encode_review_button_metadata(): - constraints = [ - SimpleNamespace( - id=1, - name="One", - description="Desc", - necessity="must", - status=ConstraintStatus.LOCKED, - scope=ConstraintScope.SESSION, - ) - ] - blocks = build_constraint_row_blocks( - constraints, thread_ts="999.000", user_id="U1", limit=20 - ) - assert blocks[0]["type"] == "section" - button = blocks[0]["accessory"] - meta = decode_metadata(button["value"]) - assert meta["constraint_id"] == "1" - assert meta["thread_ts"] == "999.000" - assert meta["user_id"] == "U1" - - -def test_parse_constraint_review_submission_extracts_status_and_description(): - state_values = { - "constraint_decision_block": { - CONSTRAINT_DECISION_ACTION_ID: {"selected_option": {"value": "accept"}} - }, - "constraint_description_block": { - CONSTRAINT_DESCRIPTION_ACTION_ID: {"value": " hello "} - }, - } - status, description = parse_constraint_review_submission(state_values) - assert status == ConstraintStatus.LOCKED - assert description == "hello" - diff --git a/tests/unit/constraints/test_slack_constraint_review_all_action.py b/tests/unit/constraints/test_slack_constraint_review_all_action.py deleted file mode 100644 index 6bfd1d42..00000000 --- a/tests/unit/constraints/test_slack_constraint_review_all_action.py +++ /dev/null @@ -1,279 +0,0 @@ -from __future__ import annotations - -import types -from types import SimpleNamespace - -import pytest - -pytest.importorskip("autogen_agentchat") - -from fateforger.agents.timeboxing.preferences import ConstraintStatus -from fateforger.core.config import settings -from fateforger.slack_bot.constraint_review import ( - CONSTRAINT_REVIEW_ALL_ACTION_ID, - CONSTRAINT_REVIEW_LIST_VIEW_CALLBACK_ID, - LEGACY_CONSTRAINT_REVIEW_ALL_ACTION_ID, - encode_metadata, -) -from fateforger.slack_bot.focus import FocusManager -from fateforger.slack_bot.handlers import register_handlers - - -class _FakeRuntime: - async def send_message(self, *_args, **_kwargs): - return None - - -class _FakeConstraintStore: - def __init__(self) -> None: - self.calls: list[dict[str, str]] = [] - - async def list_constraints( - self, - *, - user_id: str, - channel_id: str | None = None, - thread_ts: str | None = None, - ): - self.calls.append( - { - "user_id": user_id, - "channel_id": channel_id or "", - "thread_ts": thread_ts or "", - } - ) - return [ - SimpleNamespace( - id=1, - name="Keep lunch break", - description="Reserve 12:00-13:00", - necessity="must", - status=ConstraintStatus.LOCKED, - scope="session", - ), - SimpleNamespace( - id=2, - name="Declined rule", - description="Should be filtered from list", - necessity="should", - status=ConstraintStatus.DECLINED, - scope="session", - ), - ] - - -class _FakeClient: - def __init__(self) -> None: - self.opened: list[dict] = [] - - async def views_open(self, **payload): - self.opened.append(payload) - return {"ok": True} - - -class _FakeApp: - def __init__(self, client) -> None: - self.client = client - self.actions: dict[str, object] = {} - - def _register(self, bucket: dict[str, object], key: str): - def decorator(fn): - bucket[key] = fn - return fn - - return decorator - - def action(self, action_id: str): - return self._register(self.actions, action_id) - - def event(self, event_name: str): - return self._register({}, event_name) - - def command(self, command_name: str): - return self._register({}, command_name) - - def view(self, callback_id: str): - return self._register({}, callback_id) - - -@pytest.mark.asyncio -async def test_constraint_review_all_action_opens_list_modal(monkeypatch) -> None: - store = _FakeConstraintStore() - - monkeypatch.setattr( - settings, "database_url", "sqlite+aiosqlite:///:memory:", raising=False - ) - monkeypatch.setattr( - "fateforger.slack_bot.handlers.create_async_engine", - lambda *_args, **_kwargs: object(), - ) - - async def _noop(*_args, **_kwargs): - return None - - monkeypatch.setattr("fateforger.slack_bot.handlers.ensure_constraint_schema", _noop) - monkeypatch.setattr( - "fateforger.slack_bot.handlers.async_sessionmaker", - lambda *_args, **_kwargs: object(), - ) - monkeypatch.setattr( - "fateforger.slack_bot.handlers.ConstraintStore", - lambda _sessionmaker: store, - ) - - client = _FakeClient() - app = _FakeApp(client) - focus = FocusManager( - ttl_seconds=3600, - allowed_agents=["receptionist_agent", "revisor_agent", "tasks_agent"], - ) - register_handlers( - app=app, - runtime=_FakeRuntime(), - focus=focus, - default_agent="receptionist_agent", - ) - - handler = app.actions[CONSTRAINT_REVIEW_ALL_ACTION_ID] - ack_calls: list[bool] = [] - - async def _ack(): - ack_calls.append(True) - - await handler( - ack=_ack, - body={ - "actions": [ - { - "action_id": CONSTRAINT_REVIEW_ALL_ACTION_ID, - "value": encode_metadata({"thread_ts": "T1", "user_id": "U1"}), - } - ], - "channel": {"id": "C1"}, - "message": {"ts": "T1"}, - "trigger_id": "TRIGGER-1", - "user": {"id": "U1"}, - }, - client=client, - logger=types.SimpleNamespace(info=lambda *a, **k: None), - ) - - assert ack_calls == [True] - assert store.calls == [{"user_id": "U1", "channel_id": "C1", "thread_ts": "T1"}] - assert client.opened - opened = client.opened[0] - assert opened["trigger_id"] == "TRIGGER-1" - assert opened["view"]["callback_id"] == CONSTRAINT_REVIEW_LIST_VIEW_CALLBACK_ID - view_text = "\n".join( - block.get("text", {}).get("text", "") - for block in opened["view"]["blocks"] - if isinstance(block, dict) and block.get("type") == "section" - ) - assert "Keep lunch break" in view_text - assert "Declined rule" not in view_text - - -@pytest.mark.asyncio -async def test_constraint_review_all_action_acks_and_noops_without_metadata(monkeypatch) -> None: - monkeypatch.setattr(settings, "database_url", "", raising=False) - - client = _FakeClient() - app = _FakeApp(client) - focus = FocusManager( - ttl_seconds=3600, - allowed_agents=["receptionist_agent", "revisor_agent", "tasks_agent"], - ) - register_handlers( - app=app, - runtime=_FakeRuntime(), - focus=focus, - default_agent="receptionist_agent", - ) - handler = app.actions[CONSTRAINT_REVIEW_ALL_ACTION_ID] - ack_calls: list[bool] = [] - - async def _ack(): - ack_calls.append(True) - - await handler( - ack=_ack, - body={ - "actions": [{"action_id": CONSTRAINT_REVIEW_ALL_ACTION_ID, "value": ""}], - "channel": {"id": "C1"}, - "message": {"ts": "T1"}, - "user": {"id": "U1"}, - }, - client=client, - logger=types.SimpleNamespace(info=lambda *a, **k: None), - ) - - assert ack_calls == [True] - assert client.opened == [] - - -@pytest.mark.asyncio -async def test_constraint_review_all_action_legacy_id_remains_supported(monkeypatch) -> None: - store = _FakeConstraintStore() - monkeypatch.setattr( - settings, "database_url", "sqlite+aiosqlite:///:memory:", raising=False - ) - monkeypatch.setattr( - "fateforger.slack_bot.handlers.create_async_engine", - lambda *_args, **_kwargs: object(), - ) - - async def _noop(*_args, **_kwargs): - return None - - monkeypatch.setattr("fateforger.slack_bot.handlers.ensure_constraint_schema", _noop) - monkeypatch.setattr( - "fateforger.slack_bot.handlers.async_sessionmaker", - lambda *_args, **_kwargs: object(), - ) - monkeypatch.setattr( - "fateforger.slack_bot.handlers.ConstraintStore", - lambda _sessionmaker: store, - ) - - client = _FakeClient() - app = _FakeApp(client) - focus = FocusManager( - ttl_seconds=3600, - allowed_agents=["receptionist_agent", "revisor_agent", "tasks_agent"], - ) - register_handlers( - app=app, - runtime=_FakeRuntime(), - focus=focus, - default_agent="receptionist_agent", - ) - - assert CONSTRAINT_REVIEW_ALL_ACTION_ID in app.actions - assert LEGACY_CONSTRAINT_REVIEW_ALL_ACTION_ID in app.actions - - handler = app.actions[LEGACY_CONSTRAINT_REVIEW_ALL_ACTION_ID] - ack_calls: list[bool] = [] - - async def _ack(): - ack_calls.append(True) - - await handler( - ack=_ack, - body={ - "actions": [ - { - "action_id": LEGACY_CONSTRAINT_REVIEW_ALL_ACTION_ID, - "value": encode_metadata({"thread_ts": "T1", "user_id": "U1"}), - } - ], - "channel": {"id": "C1"}, - "message": {"ts": "T1"}, - "trigger_id": "TRIGGER-1", - "user": {"id": "U1"}, - }, - client=client, - logger=types.SimpleNamespace(info=lambda *a, **k: None), - ) - - assert ack_calls == [True] - assert client.opened diff --git a/tests/unit/constraints/test_stage_message_decomposition.py b/tests/unit/constraints/test_stage_message_decomposition.py deleted file mode 100644 index ae513f99..00000000 --- a/tests/unit/constraints/test_stage_message_decomposition.py +++ /dev/null @@ -1,128 +0,0 @@ -"""The stage result must arrive as a new message, never rewrite the old one. - -One message carried the day overview, the constraint list, three expanded -bodies and five buttons, and every stage rewrote all of it. It grew until -`chat.update` returned `msg_too_long`, and because the progress channel and the -error channel were the same message, the session went silent with no way to say -why β€” twelve minutes indistinguishable from working. - -The repair is not fewer edits. It is never re-editing the part that -accumulates. -""" - -from __future__ import annotations - -from typing import Any - -import pytest - -from fateforger.slack_bot.timeboxing_stage_actions import ( - TimeboxingStageActionCoordinator, - TimeboxingStageActionPayload, - _stage_action_receipt_text, -) - - -class FakeClient: - def __init__(self) -> None: - self.posted: list[dict] = [] - self.updated: list[dict] = [] - - async def chat_postMessage(self, **payload): - self.posted.append(payload) - return {"ts": "1772.9", "channel": payload.get("channel")} - - async def chat_update(self, **payload): - self.updated.append(payload) - return {"ok": True} - - -class FakeRuntime: - """Returns a stage result large enough to have blown the old limit.""" - - def __init__(self, blocks: int = 40) -> None: - self._blocks = blocks - - async def send_message(self, message, recipient): - return type( - "R", - (), - { - "text": "Stage 3 β€” Skeleton", - "blocks": [{"type": "section", "text": f"block {i}"} for i in range(self._blocks)], - }, - )() - - -def _payload(client_channel="C1") -> TimeboxingStageActionPayload: - from fateforger.slack_bot.constraint_review import encode_metadata - - value = encode_metadata( - {"channel_id": "C1", "thread_ts": "1772.0", "user_id": "U_HUGO"} - ) - return TimeboxingStageActionPayload( - value=value, - prompt_channel_id=client_channel, - prompt_ts="1772.0", - actor_user_id="U_HUGO", - ) - - -async def _run(client, runtime, action="proceed"): - coord = TimeboxingStageActionCoordinator(runtime=runtime, client=client) - await coord.handle_action(payload=_payload(), action=action) - - -async def test_the_stage_result_is_posted_not_written_onto_the_button_message(): - """The artifacts must arrive, not be rewritten into the accumulating message.""" - client, runtime = FakeClient(), FakeRuntime() - await _run(client, runtime) - - assert client.posted, "the stage result was never posted as its own message" - result = client.posted[-1] - assert len(result.get("blocks") or []) == 40 - assert result["thread_ts"] == "1772.0", "the result belongs in the thread" - - -async def test_the_button_message_never_receives_the_artifacts(): - """This is the message still being edited; it must not grow with the plan.""" - client, runtime = FakeClient(), FakeRuntime() - await _run(client, runtime) - - final = client.updated[-1] - assert final["ts"] == "1772.0", "the receipt replaces the button message" - assert len(final.get("blocks") or []) <= 1 - assert "block 0" not in str(final), "artifacts leaked into the edited message" - - -async def test_the_receipt_stays_the_same_size_however_large_the_plan_is(): - """The bug reproduced as an assertion: edited content must not scale.""" - small, large = FakeClient(), FakeClient() - await _run(small, FakeRuntime(blocks=1)) - await _run(large, FakeRuntime(blocks=400)) - - assert len(str(small.updated[-1])) == len(str(large.updated[-1])) - - -async def test_the_receipt_names_the_action_rather_than_guessing_a_stage(): - """Button metadata carries channel, thread and user β€” not a stage. - - Inventing a stage name here would render a guess as fact. - """ - assert "proceed" in _stage_action_receipt_text("proceed") - assert "cancel" in _stage_action_receipt_text("cancel") - - -async def test_a_runtime_failure_still_lands_on_the_small_message(): - """Once artifacts move out, the button message is the only error channel.""" - - class Broken: - async def send_message(self, message, recipient): - raise RuntimeError("runtime unreachable") - - client = FakeClient() - await _run(client, Broken()) - - assert not client.posted, "nothing should be posted when the stage failed" - assert client.updated, "the failure must reach Slack" - assert "warning" in str(client.updated[-1]).lower() diff --git a/tests/unit/constraints/test_timeboxing_constraint_dedupe.py b/tests/unit/constraints/test_timeboxing_constraint_dedupe.py deleted file mode 100644 index d93fb71b..00000000 --- a/tests/unit/constraints/test_timeboxing_constraint_dedupe.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone - -from fateforger.agents.timeboxing.agent import _dedupe_constraints -from fateforger.agents.timeboxing.preferences import ( - Constraint, - ConstraintNecessity, - ConstraintScope, - ConstraintSource, - ConstraintStatus, -) - - -def _constraint( - *, - name: str, - description: str, - status: ConstraintStatus, - updated_at: datetime, - uid: str | None = None, -) -> Constraint: - hints = {"rule_kind": "commute"} - if uid: - hints["uid"] = uid - return Constraint( - user_id="U1", - channel_id=None, - thread_ts=None, - name=name, - description=description, - necessity=ConstraintNecessity.MUST, - status=status, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - tags=["commute"], - hints=hints, - start_date=None, - end_date=None, - days_of_week=[], - timezone="Europe/Amsterdam", - updated_at=updated_at, - ) - - -def test_dedupe_constraints_prefers_locked_over_proposed_for_same_semantics() -> None: - proposed = _constraint( - name="Commute Home", - description="Commute office to home", - status=ConstraintStatus.PROPOSED, - updated_at=datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc), - ) - locked = _constraint( - name="Commute Home", - description="Commute office to home", - status=ConstraintStatus.LOCKED, - updated_at=datetime(2026, 3, 6, 7, 0, tzinfo=timezone.utc), - ) - - deduped = _dedupe_constraints([proposed, locked]) - - assert len(deduped) == 1 - assert deduped[0].status == ConstraintStatus.LOCKED - - -def test_dedupe_constraints_keeps_distinct_uids_even_with_same_text() -> None: - first = _constraint( - name="Morning Routine", - description="Normal morning routine", - status=ConstraintStatus.LOCKED, - updated_at=datetime(2026, 3, 6, 7, 0, tzinfo=timezone.utc), - uid="tb:pref:1", - ) - second = _constraint( - name="Morning Routine", - description="Normal morning routine", - status=ConstraintStatus.LOCKED, - updated_at=datetime(2026, 3, 6, 8, 0, tzinfo=timezone.utc), - uid="tb:pref:2", - ) - - deduped = _dedupe_constraints([first, second]) - - assert len(deduped) == 2 diff --git a/tests/unit/constraints/test_timeboxing_constraint_priority.py b/tests/unit/constraints/test_timeboxing_constraint_priority.py deleted file mode 100644 index 07135b8e..00000000 --- a/tests/unit/constraints/test_timeboxing_constraint_priority.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -import pytest - -pytest.importorskip("autogen_agentchat") - -from fateforger.agents.timeboxing.agent import _constraint_priority -from fateforger.agents.timeboxing.preferences import ( - Constraint, - ConstraintNecessity, - ConstraintScope, - ConstraintSource, - ConstraintStatus, -) - - -def _constraint(name: str, necessity: ConstraintNecessity) -> Constraint: - return Constraint( - user_id="u1", - channel_id="c1", - thread_ts="t1", - name=name, - description=f"{name} description", - necessity=necessity, - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - ) - - -def test_constraint_priority_orders_must_should_prefer() -> None: - constraints = [ - _constraint("C prefer", ConstraintNecessity.PREFER), - _constraint("A must", ConstraintNecessity.MUST), - _constraint("B should", ConstraintNecessity.SHOULD), - ] - - ranked = sorted(constraints, key=_constraint_priority) - assert [constraint.necessity for constraint in ranked] == [ - ConstraintNecessity.MUST, - ConstraintNecessity.SHOULD, - ConstraintNecessity.PREFER, - ] diff --git a/tests/unit/constraints/test_timeboxing_constraint_retriever_startup_prefetch.py b/tests/unit/constraints/test_timeboxing_constraint_retriever_startup_prefetch.py deleted file mode 100644 index fbbc740d..00000000 --- a/tests/unit/constraints/test_timeboxing_constraint_retriever_startup_prefetch.py +++ /dev/null @@ -1,131 +0,0 @@ -from __future__ import annotations - -from datetime import date - -import pytest - -from fateforger.agents.timeboxing.constraint_retriever import ( - STARTUP_PREFETCH_TAG, - ConstraintRetriever, -) -from fateforger.agents.timeboxing.stage_gating import TimeboxingStage - - -class _DummyClient: - def __init__(self, *, startup_rows: list[dict], broad_rows: list[dict]) -> None: - self.startup_rows = startup_rows - self.broad_rows = broad_rows - self.query_types_calls: list[dict] = [] - self.query_constraints_calls: list[dict] = [] - - async def query_types(self, *, stage: str | None, event_types: list[str] | None): - self.query_types_calls.append( - { - "stage": stage, - "event_types": list(event_types or []), - } - ) - return [{"type_id": "sleep", "name": "Sleep", "count": 10}] - - async def query_constraints( - self, - *, - filters: dict, - type_ids: list[str] | None, - tags: list[str] | None, - sort, - limit: int, - ): - self.query_constraints_calls.append( - { - "filters": dict(filters), - "type_ids": list(type_ids or []), - "tags": list(tags or []), - "limit": limit, - "sort": sort, - } - ) - if tags == [STARTUP_PREFETCH_TAG]: - return list(self.startup_rows) - return list(self.broad_rows) - - -@pytest.mark.asyncio -async def test_collect_retriever_prefers_startup_prefetch_tagged_rows() -> None: - retriever = ConstraintRetriever(max_type_ids=5, query_limit=25) - client = _DummyClient( - startup_rows=[{"uid": "u1", "name": "Sleep default"}], - broad_rows=[{"uid": "u2", "name": "Fallback row"}], - ) - - _plan, rows = await retriever.retrieve( - client=client, - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - planned_day=date(2026, 2, 18), - work_window=None, - sleep_target=None, - immovables=[], - block_plan=None, - frame_facts={}, - ) - - assert rows == [{"uid": "u1", "name": "Sleep default"}] - assert client.query_types_calls == [] - assert len(client.query_constraints_calls) == 1 - call = client.query_constraints_calls[0] - assert call["tags"] == [STARTUP_PREFETCH_TAG] - assert call["filters"]["event_types_any"] == [] - assert call["filters"]["scopes_any"] == ["profile", "datespan"] - - -@pytest.mark.asyncio -async def test_collect_retriever_falls_back_to_broad_when_no_startup_rows() -> None: - retriever = ConstraintRetriever(max_type_ids=5, query_limit=25) - client = _DummyClient( - startup_rows=[], - broad_rows=[{"uid": "u2", "name": "Fallback row"}], - ) - - _plan, rows = await retriever.retrieve( - client=client, - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - planned_day=date(2026, 2, 18), - work_window=None, - sleep_target=None, - immovables=[], - block_plan=None, - frame_facts={}, - ) - - assert rows == [{"uid": "u2", "name": "Fallback row"}] - assert client.query_types_calls == [] - assert len(client.query_constraints_calls) == 2 - assert client.query_constraints_calls[0]["tags"] == [STARTUP_PREFETCH_TAG] - assert client.query_constraints_calls[1]["tags"] == [] - - -@pytest.mark.asyncio -async def test_non_collect_retriever_does_not_use_startup_prefetch_tag() -> None: - retriever = ConstraintRetriever(max_type_ids=5, query_limit=25) - client = _DummyClient( - startup_rows=[{"uid": "u1", "name": "Should not be used"}], - broad_rows=[{"uid": "u2", "name": "Refine row"}], - ) - - _plan, rows = await retriever.retrieve( - client=client, - stage=TimeboxingStage.REFINE, - planned_day=date(2026, 2, 18), - work_window=None, - sleep_target=None, - immovables=[], - block_plan=None, - frame_facts={}, - ) - - assert rows == [{"uid": "u2", "name": "Refine row"}] - assert len(client.query_types_calls) == 1 - assert len(client.query_constraints_calls) == 1 - call = client.query_constraints_calls[0] - assert call["tags"] == [] - assert "scopes_any" not in call["filters"] diff --git a/tests/unit/constraints/test_timeboxing_constraint_search_tool_strict.py b/tests/unit/constraints/test_timeboxing_constraint_search_tool_strict.py deleted file mode 100644 index b7119d47..00000000 --- a/tests/unit/constraints/test_timeboxing_constraint_search_tool_strict.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -from autogen_core import CancellationToken - -from fateforger.agents.timeboxing import agent as timeboxing_agent_mod - - -def test_constraint_search_tool_is_strict() -> None: - """Ensure stage-gating search tool uses strict function-tool schema.""" - agent = timeboxing_agent_mod.TimeboxingFlowAgent.__new__( - timeboxing_agent_mod.TimeboxingFlowAgent - ) - agent._constraint_memory_client = None - tool = timeboxing_agent_mod.TimeboxingFlowAgent._build_constraint_search_tool(agent) - - assert tool.schema.get("strict") is True - params = tool.schema.get("parameters", {}) - required = set(params.get("required", [])) - assert {"queries", "planned_date", "stage"} == required - query_items = ( - params.get("properties", {}) - .get("queries", {}) - .get("items", {}) - ) - assert query_items.get("type") == "object" - assert query_items.get("additionalProperties") is False - item_required = set(query_items.get("required", [])) - assert { - "label", - "text_query", - "event_types", - "tags", - "statuses", - "scopes", - "necessities", - "limit", - }.issubset(item_required) - - -async def test_constraint_search_tool_skips_empty_stage1_query(monkeypatch) -> None: - """Stage 1 no-op query facets should not hit Notion search path.""" - called = {"search": 0} - - async def _fake_search_constraints(*_args, **_kwargs): - called["search"] += 1 - return "should_not_be_called" - - monkeypatch.setattr( - timeboxing_agent_mod, - "search_constraints", - _fake_search_constraints, - ) - - agent = timeboxing_agent_mod.TimeboxingFlowAgent.__new__( - timeboxing_agent_mod.TimeboxingFlowAgent - ) - agent._constraint_memory_client = None - tool = timeboxing_agent_mod.TimeboxingFlowAgent._build_constraint_search_tool(agent) - out = await tool.run_json( - { - # OpenAI strict schemas require all query keys to be present. - "queries": [ - { - "label": "empty", - "text_query": None, - "event_types": None, - "tags": None, - "statuses": None, - "scopes": None, - "necessities": None, - "limit": 20, - } - ], - "planned_date": "2026-02-18", - "stage": "CollectConstraints", - }, - CancellationToken(), - ) - - assert called["search"] == 0 - assert "Skipped search_constraints for Stage 1" in str(out) diff --git a/tests/unit/constraints/test_timeboxing_constraint_selection.py b/tests/unit/constraints/test_timeboxing_constraint_selection.py deleted file mode 100644 index 57bd213e..00000000 --- a/tests/unit/constraints/test_timeboxing_constraint_selection.py +++ /dev/null @@ -1,301 +0,0 @@ -from __future__ import annotations - -import pytest - -pytest.importorskip("autogen_agentchat") - -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.constants import TIMEBOXING_LIMITS -from fateforger.agents.timeboxing.preferences import ( - Constraint, - ConstraintDayOfWeek, - ConstraintNecessity, - ConstraintScope, - ConstraintSource, - ConstraintStatus, -) -from fateforger.agents.timeboxing.stage_gating import TimeboxingStage - - -def _constraint( - *, - name: str, - uid: str, - necessity: ConstraintNecessity = ConstraintNecessity.SHOULD, -) -> Constraint: - return Constraint( - name=name, - description=name, - necessity=necessity, - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - hints={"uid": uid}, - user_id="u1", - channel_id="c1", - thread_ts="t1", - ) - - -@pytest.mark.asyncio -async def test_collect_constraints_uses_relevant_durable_stage_sets() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._constraint_store = None - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", committed=True) - session.stage = TimeboxingStage.REFINE - session.durable_constraints_by_stage = { - TimeboxingStage.COLLECT_CONSTRAINTS.value: [_constraint(name="collect", uid="u-collect")], - TimeboxingStage.SKELETON.value: [_constraint(name="skeleton", uid="u-skeleton")], - TimeboxingStage.REFINE.value: [_constraint(name="refine", uid="u-refine")], - } - - active = await TimeboxingFlowAgent._collect_constraints(agent, session) - - assert {item.name for item in active} == {"collect", "refine"} - - -@pytest.mark.asyncio -async def test_collect_constraints_logs_raw_and_applicable_active_counts() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._constraint_store = None - events: list[tuple[str, dict[str, object]]] = [] - agent._session_debug = lambda _session, event, **kwargs: events.append((event, kwargs)) - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - committed=True, - planned_date="2026-03-06", # Friday - ) - session.stage = TimeboxingStage.COLLECT_CONSTRAINTS - applicable = Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="Friday planning", - description="Works on Friday", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - days_of_week=[ConstraintDayOfWeek.FR], - hints={"uid": "applicable"}, - ) - wrong_day = Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="Monday only", - description="Only for Monday", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - days_of_week=[ConstraintDayOfWeek.MO], - hints={"uid": "wrong-day"}, - ) - expired = Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="Expired window", - description="No longer applicable", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - scope=ConstraintScope.DATESPAN, - start_date="2026-02-01", - end_date="2026-02-10", - hints={"uid": "expired"}, - ) - session.durable_constraints_by_stage = { - TimeboxingStage.COLLECT_CONSTRAINTS.value: [applicable, wrong_day, expired] - } - - active = await TimeboxingFlowAgent._collect_constraints(agent, session) - - assert [item.name for item in active] == ["Friday planning"] - assert session.active_constraints_raw_count == 3 - assert session.active_constraints_applicable_count == 1 - snapshots = [payload for event, payload in events if event == "constraints_active_snapshot"] - assert snapshots, "Expected constraints_active_snapshot debug event." - assert snapshots[-1]["active_raw_count"] == 3 - assert snapshots[-1]["active_applicable_count"] == 1 - assert snapshots[-1]["active_filtered_out_count"] == 2 - - -@pytest.mark.asyncio -async def test_collect_constraints_reconciles_and_filters_stage_relevance() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._constraint_store = None - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - committed=True, - planned_date="2026-03-06", - ) - session.stage = TimeboxingStage.COLLECT_CONSTRAINTS - session.durable_constraints_by_stage = { - TimeboxingStage.COLLECT_CONSTRAINTS.value: [ - Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="Legacy low signal", - description="Old unstructured should preference.", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - hints={}, - ), - Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="Structured morning routine v1", - description="Morning routine 1h", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - hints={ - "aspect_classification": { - "aspect_id": "morning_routine", - "duration_min": 60, - "is_startup_prefetch": True, - } - }, - ), - Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="Structured morning routine v2", - description="Morning routine one hour", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - hints={ - "aspect_classification": { - "aspect_id": "morning_routine", - "duration_min": 60, - "is_startup_prefetch": True, - } - }, - ), - Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="Legacy deep work default", - description="Old profile deep work preference.", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - hints={ - "aspect_classification": { - "aspect_id": "deep_work", - "duration_min": 90, - } - }, - ), - Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="Locked hard guardrail", - description="Must keep hard stop.", - necessity=ConstraintNecessity.MUST, - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - hints={}, - ), - Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="Sci-fi reading breaks", - description="Old hobby preference not requested now.", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - hints={ - "aspect_classification": { - "aspect_id": "reading_break", - "duration_min": 15, - } - }, - ), - Constraint( - user_id="u1", - channel_id="c1", - thread_ts="t1", - name="Session ask deep work", - description="Current-thread deep-work request", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.SESSION, - hints={"aspect_classification": {"aspect_id": "deep_work"}}, - ), - Constraint( - user_id="u1", - channel_id="c1", - thread_ts="t1", - name="Session ask", - description="Current-thread specific request", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.SESSION, - hints={}, - ), - ] - } - - active = await TimeboxingFlowAgent._collect_constraints(agent, session) - - assert session.active_constraints_raw_count == 8 - assert session.active_constraints_applicable_count == 8 - assert session.active_constraints_selected_count == 4 - names = {item.name for item in active} - assert "Legacy low signal" not in names - assert "Structured morning routine v1" in names - assert "Structured morning routine v2" not in names - assert "Legacy deep work default" not in names - assert "Sci-fi reading breaks" not in names - assert "Locked hard guardrail" in names - assert "Session ask deep work" in names - assert "Session ask" in names - - -def test_select_constraints_for_refine_patcher_caps_and_preserves_must() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", committed=True) - session.stage = TimeboxingStage.REFINE - constraints = [ - _constraint( - name=f"must-{idx}", - uid=f"must-{idx}", - necessity=ConstraintNecessity.MUST, - ) - for idx in range(2) - ] + [ - _constraint(name=f"should-{idx}", uid=f"should-{idx}") - for idx in range(TIMEBOXING_LIMITS.refine_patcher_constraint_limit + 20) - ] - - selected = TimeboxingFlowAgent._select_constraints_for_refine_patcher( - agent, - session=session, - constraints=constraints, - ) - - assert len(selected) == TIMEBOXING_LIMITS.refine_patcher_constraint_limit - assert {"must-0", "must-1"}.issubset({item.name for item in selected}) diff --git a/tests/unit/constraints/test_timeboxing_constraint_store_canonicalization.py b/tests/unit/constraints/test_timeboxing_constraint_store_canonicalization.py deleted file mode 100644 index 47f0bd72..00000000 --- a/tests/unit/constraints/test_timeboxing_constraint_store_canonicalization.py +++ /dev/null @@ -1,162 +0,0 @@ -from __future__ import annotations - -import pytest -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine - -from fateforger.agents.timeboxing.preferences import ( - Constraint, - ConstraintBase, - ConstraintNecessity, - ConstraintScope, - ConstraintSource, - ConstraintStatus, - ConstraintStore, - ensure_constraint_schema, -) - - -def _shared_constraint(*, status: ConstraintStatus) -> ConstraintBase: - return ConstraintBase( - name="No calls after 17:00", - description="Protect evening deep work.", - necessity=ConstraintNecessity.SHOULD, - status=status, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - hints={"uid": "uid:no_calls_after_17"}, - ) - - -@pytest.mark.asyncio -async def test_upsert_constraints_dedupes_shared_scope_across_threads() -> None: - engine = create_async_engine("sqlite+aiosqlite:///:memory:") - try: - await ensure_constraint_schema(engine) - store = ConstraintStore(async_sessionmaker(engine, expire_on_commit=False)) - - first = await store.upsert_constraints( - user_id="u1", - channel_id="c1", - thread_ts="t1", - constraints=[_shared_constraint(status=ConstraintStatus.PROPOSED)], - ) - second = await store.upsert_constraints( - user_id="u1", - channel_id="c1", - thread_ts="t2", - constraints=[_shared_constraint(status=ConstraintStatus.PROPOSED)], - ) - - rows = await store.list_constraints( - user_id="u1", - channel_id="c1", - scope=ConstraintScope.PROFILE, - ) - assert first["added"] == 1 - assert second["added"] == 0 - assert second["skipped"] == 1 - assert len(rows) == 1 - assert rows[0].thread_ts is None - finally: - await engine.dispose() - - -@pytest.mark.asyncio -async def test_prune_shared_constraints_dry_run_reports_without_mutation() -> None: - engine = create_async_engine("sqlite+aiosqlite:///:memory:") - try: - await ensure_constraint_schema(engine) - sessionmaker = async_sessionmaker(engine, expire_on_commit=False) - store = ConstraintStore(sessionmaker) - - # Insert 3 rows directly (simulating pre-deduplication dirty state) - async with sessionmaker() as session: - for status in [ - ConstraintStatus.PROPOSED, - ConstraintStatus.LOCKED, - ConstraintStatus.DECLINED, - ]: - session.add( - Constraint( - user_id="u1", - name="No calls after 17:00", - description="Protect evening deep work.", - necessity=ConstraintNecessity.SHOULD, - status=status, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - hints={"uid": "uid:no_calls_after_17"}, - ) - ) - await session.commit() - - before = await store.list_constraints( - user_id="u1", - scope=ConstraintScope.PROFILE, - ) - preview = await store.prune_shared_constraints( - user_id="u1", - dry_run=True, - ) - after = await store.list_constraints( - user_id="u1", - scope=ConstraintScope.PROFILE, - ) - - assert len(before) == 3 - assert len(after) == 3 - assert preview["raw_shared_rows"] == 3 - assert preview["canonical_shared_rows"] == 1 - assert preview["duplicates_found"] == 2 - assert preview["duplicates_archived"] == 0 # dry_run: no mutation - assert preview["duplicate_groups"] == 1 - finally: - await engine.dispose() - - -@pytest.mark.asyncio -async def test_prune_shared_constraints_apply_keeps_single_canonical_row() -> None: - engine = create_async_engine("sqlite+aiosqlite:///:memory:") - try: - await ensure_constraint_schema(engine) - sessionmaker = async_sessionmaker(engine, expire_on_commit=False) - store = ConstraintStore(sessionmaker) - - # Insert 3 rows directly (simulating pre-deduplication dirty state) - async with sessionmaker() as session: - for status in [ - ConstraintStatus.PROPOSED, - ConstraintStatus.LOCKED, - ConstraintStatus.DECLINED, - ]: - session.add( - Constraint( - user_id="u1", - name="No calls after 17:00", - description="Protect evening deep work.", - necessity=ConstraintNecessity.SHOULD, - status=status, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - hints={"uid": "uid:no_calls_after_17"}, - ) - ) - await session.commit() - - apply_result = await store.prune_shared_constraints( - user_id="u1", - dry_run=False, - ) - # Use include_shared_scopes to get the canonical view (archive approach keeps rows) - remaining = await store.list_constraints( - user_id="u1", - scope=ConstraintScope.PROFILE, - include_shared_scopes=True, - ) - - assert apply_result["duplicates_found"] == 2 - assert apply_result["duplicates_archived"] >= 1 # at least PROPOSED archived - assert len(remaining) == 1 - assert remaining[0].status == ConstraintStatus.LOCKED - finally: - await engine.dispose() diff --git a/tests/unit/constraints/test_timeboxing_constraint_store_shared_scopes.py b/tests/unit/constraints/test_timeboxing_constraint_store_shared_scopes.py deleted file mode 100644 index 09d5bfa6..00000000 --- a/tests/unit/constraints/test_timeboxing_constraint_store_shared_scopes.py +++ /dev/null @@ -1,207 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone - -import pytest -from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine - -from fateforger.agents.timeboxing.preferences import ( - Constraint, - ConstraintBase, - ConstraintNecessity, - ConstraintScope, - ConstraintSource, - ConstraintStatus, - ConstraintStore, - ensure_constraint_schema, -) - - -@pytest.mark.asyncio -async def test_shared_scope_add_is_upserted_across_threads(tmp_path) -> None: - db_path = tmp_path / "constraints.db" - engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}") - sessionmaker = async_sessionmaker(engine, expire_on_commit=False) - await ensure_constraint_schema(engine) - store = ConstraintStore(sessionmaker) - - payload = ConstraintBase( - name="Morning routine", - description="Default morning routine", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - tags=["routine"], - hints={"rule_kind": "sequencing"}, - ) - await store.add_constraints( - user_id="U1", - channel_id="C1", - thread_ts="t1", - constraints=[payload], - ) - await store.add_constraints( - user_id="U1", - channel_id="C2", - thread_ts="t2", - constraints=[payload], - ) - - rows = await store.list_constraints(user_id="U1") - assert len(rows) == 1 - assert rows[0].scope == ConstraintScope.PROFILE - assert rows[0].thread_ts is None - assert rows[0].channel_id is None - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_shared_scope_canonical_precedence_prefers_locked(tmp_path) -> None: - db_path = tmp_path / "constraints.db" - engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}") - sessionmaker = async_sessionmaker(engine, expire_on_commit=False) - await ensure_constraint_schema(engine) - store = ConstraintStore(sessionmaker) - - proposed = ConstraintBase( - name="Office commute", - description="Commute to office", - necessity=ConstraintNecessity.MUST, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - tags=["commute"], - hints={"rule_kind": "sequencing"}, - ) - locked = ConstraintBase( - name="Office commute", - description="Commute to office in the morning", - necessity=ConstraintNecessity.MUST, - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - tags=["commute"], - hints={"rule_kind": "sequencing"}, - ) - await store.add_constraints( - user_id="U1", channel_id="C1", thread_ts="t1", constraints=[proposed] - ) - await store.add_constraints( - user_id="U1", channel_id="C1", thread_ts="t2", constraints=[locked] - ) - - rows = await store.list_constraints(user_id="U1") - assert len(rows) == 1 - assert rows[0].status == ConstraintStatus.LOCKED - assert rows[0].description == "Commute to office in the morning" - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_prune_shared_constraints_dry_run_reports_without_mutation(tmp_path) -> None: - db_path = tmp_path / "constraints.db" - engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}") - sessionmaker = async_sessionmaker(engine, expire_on_commit=False) - await ensure_constraint_schema(engine) - store = ConstraintStore(sessionmaker) - - async with sessionmaker() as session: - first = Constraint( - user_id="U1", - channel_id="C1", - thread_ts="t1", - name="Lunch break", - description="Lunch at 13:00", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - tags=["lunch"], - hints={"rule_kind": "buffer"}, - updated_at=datetime(2026, 3, 6, 10, 0, tzinfo=timezone.utc), - ) - second = Constraint( - user_id="U1", - channel_id="C2", - thread_ts="t2", - name="Lunch break", - description="Lunch at 13:00 duplicate", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - tags=["lunch"], - hints={"rule_kind": "buffer"}, - updated_at=datetime(2026, 3, 6, 9, 0, tzinfo=timezone.utc), - ) - session.add_all([first, second]) - await session.commit() - - result = await store.prune_shared_constraints(user_id="U1", dry_run=True) - assert result["duplicate_groups"] == 1 - assert result["duplicates_found"] == 1 - assert result["duplicates_archived"] == 0 - - rows = await store.list_constraints(user_id="U1") - assert len(rows) == 2 - - await engine.dispose() - - -@pytest.mark.asyncio -async def test_prune_shared_constraints_apply_archives_duplicates(tmp_path) -> None: - db_path = tmp_path / "constraints.db" - engine = create_async_engine(f"sqlite+aiosqlite:///{db_path}") - sessionmaker = async_sessionmaker(engine, expire_on_commit=False) - await ensure_constraint_schema(engine) - store = ConstraintStore(sessionmaker) - - async with sessionmaker() as session: - first = Constraint( - user_id="U1", - channel_id="C1", - thread_ts="t1", - name="Gym", - description="Gym in evening", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - tags=["sports"], - hints={"rule_kind": "capacity"}, - updated_at=datetime(2026, 3, 6, 10, 0, tzinfo=timezone.utc), - ) - second = Constraint( - user_id="U1", - channel_id="C2", - thread_ts="t2", - name="Gym", - description="Gym in evening duplicate", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - tags=["sports"], - hints={"rule_kind": "capacity"}, - updated_at=datetime(2026, 3, 6, 9, 0, tzinfo=timezone.utc), - ) - session.add_all([first, second]) - await session.commit() - - result = await store.prune_shared_constraints(user_id="U1", dry_run=False) - assert result["duplicate_groups"] == 1 - assert result["duplicates_found"] == 1 - assert result["duplicates_archived"] == 1 - - rows = await store.list_constraints(user_id="U1", include_shared_scopes=True) - assert len(rows) == 1 - assert rows[0].status == ConstraintStatus.LOCKED - - stats = await store.shared_scope_stats(user_id="U1") - assert stats["raw_shared_rows"] == 2 - assert stats["canonical_shared_rows"] == 1 - - await engine.dispose() diff --git a/tests/unit/constraints/test_timeboxing_durable_constraints.py b/tests/unit/constraints/test_timeboxing_durable_constraints.py deleted file mode 100644 index 5ab74ce7..00000000 --- a/tests/unit/constraints/test_timeboxing_durable_constraints.py +++ /dev/null @@ -1,670 +0,0 @@ -import asyncio -import types - -import pytest - -pytest.importorskip("autogen_agentchat") - -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.constraint_retriever import STARTUP_PREFETCH_TAG -from fateforger.agents.timeboxing.nlu import ConstraintInterpretation -from fateforger.agents.timeboxing.stage_gating import StageGateOutput, TimeboxingStage -from fateforger.agents.timeboxing.preferences import ( - ConstraintBase, - Constraint, - ConstraintNecessity, - ConstraintScope, - ConstraintSource, - ConstraintStatus, -) -from fateforger.core.config import settings - - -@pytest.mark.asyncio -async def test_durable_constraint_prefetch_populates_session(monkeypatch): - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._durable_constraint_prefetch_tasks = {} - agent._durable_constraint_prefetch_semaphore = asyncio.Semaphore(1) - monkeypatch.setattr(settings, "notion_timeboxing_parent_page_id", "parent") - - done = asyncio.Event() - - async def _fake_fetch(_self, _session, *, stage: TimeboxingStage): - if stage == TimeboxingStage.SKELETON: - done.set() - return [ - Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="No early meetings", - description="Avoid meetings before 09:00.", - necessity=ConstraintNecessity.MUST, - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - ) - ] - - agent._fetch_durable_constraints = types.MethodType(_fake_fetch, agent) - agent._collect_constraints = types.MethodType( - lambda _self, _session: asyncio.sleep(0, result=[]), agent - ) - agent._sync_durable_constraints_to_store = types.MethodType( - lambda _self, _session, *, constraints: asyncio.sleep(0), agent - ) - - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-01-21", - ) - - agent._queue_durable_constraint_prefetch(session=session, reason="test") - - await asyncio.wait_for(done.wait(), timeout=1.0) - while session.pending_durable_constraints: - await asyncio.sleep(0) - - assert TimeboxingStage.COLLECT_CONSTRAINTS.value in session.durable_constraints_by_stage - assert TimeboxingStage.SKELETON.value in session.durable_constraints_by_stage - assert TimeboxingStage.COLLECT_CONSTRAINTS.value in session.durable_constraints_loaded_stages - assert TimeboxingStage.SKELETON.value in session.durable_constraints_loaded_stages - assert session.pending_durable_constraints is False - - -@pytest.mark.asyncio -async def test_collect_constraints_merges_durable_with_session(): - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - - durable = Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="Deep work block", - description="Reserve 2 hours for deep work.", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - hints={ - "aspect_classification": { - "aspect_id": "deep_work", - "duration_min": 120, - "is_startup_prefetch": True, - } - }, - ) - local = Constraint( - user_id="u1", - channel_id="c1", - thread_ts="t1", - name="Gym", - description="Gym at 18:00.", - necessity=ConstraintNecessity.MUST, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.SESSION, - ) - - class _Store: - async def list_constraints(self, **_kwargs): - return [local] - - agent._constraint_store = _Store() - - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - ) - session.durable_constraints_by_stage[TimeboxingStage.COLLECT_CONSTRAINTS.value] = [durable] - - combined = await agent._collect_constraints(session) - - assert durable in combined - assert local in combined - assert durable in session.active_constraints - assert local in session.active_constraints - - -@pytest.mark.asyncio -async def test_collect_constraints_requests_shared_scope_fallback(monkeypatch) -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - captured_kwargs: list[dict[str, object]] = [] - monkeypatch.setattr(settings, "timeboxing_memory_backend", "constraint_mcp") - - class _Store: - async def list_constraints(self, **kwargs): - captured_kwargs.append(dict(kwargs)) - return [] - - agent._constraint_store = _Store() - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - - await agent._collect_constraints(session) - - assert captured_kwargs - assert captured_kwargs[0].get("include_shared_scopes") is True - - -@pytest.mark.asyncio -async def test_collect_constraints_skips_local_shared_when_graphiti_stage_durable_loaded( - monkeypatch, -) -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - captured_kwargs: list[dict[str, object]] = [] - - durable = Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="Durable sleep", - description="Sleep at 23:00", - necessity=ConstraintNecessity.MUST, - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - hints={"uid": "tb:sleep"}, - ) - local_session = Constraint( - user_id="u1", - channel_id="c1", - thread_ts="t1", - name="Session running", - description="Run at 18:30", - necessity=ConstraintNecessity.MUST, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.SESSION, - ) - local_shared = Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="Legacy profile noise", - description="Generic profile rule", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - ) - - class _Store: - async def list_constraints(self, **kwargs): - captured_kwargs.append(dict(kwargs)) - if kwargs.get("include_shared_scopes"): - return [local_session, local_shared] - return [local_session] - - monkeypatch.setattr(settings, "timeboxing_memory_backend", "graphiti") - agent._constraint_store = _Store() - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", planned_date="2026-03-07") - session.stage = TimeboxingStage.COLLECT_CONSTRAINTS - session.durable_constraints_by_stage[TimeboxingStage.COLLECT_CONSTRAINTS.value] = [ - durable - ] - - combined = await agent._collect_constraints(session) - - assert captured_kwargs - assert captured_kwargs[0].get("include_shared_scopes") is False - assert durable in combined - assert local_session in combined - assert local_shared not in combined - - -@pytest.mark.asyncio -async def test_collect_constraints_filters_irrelevant_local_shared_noise() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - - local_noise = Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="Old broad profile", - description="Generic preference without structure", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - ) - local_structured = Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="Morning commute", - description="Commute 30m", - necessity=ConstraintNecessity.MUST, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - hints={ - "aspect_classification": { - "aspect_id": "morning_commute", - "aspect_label": "Morning commute", - "category": "transport", - "frame_slot": "work_window", - "is_startup_prefetch": True, - } - }, - ) - local_locked = Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="Sleep lock", - description="Sleep 23:00-07:00", - necessity=ConstraintNecessity.MUST, - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - ) - - class _Store: - async def list_constraints(self, **_kwargs): - return [local_noise, local_structured, local_locked] - - agent._constraint_store = _Store() - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", planned_date="2026-03-07") - session.stage = TimeboxingStage.COLLECT_CONSTRAINTS - - combined = await agent._collect_constraints(session) - - names = {item.name for item in combined} - assert "Old broad profile" not in names - assert "Morning commute" in names - assert "Sleep lock" in names - - -@pytest.mark.asyncio -async def test_collect_constraints_local_decline_suppresses_durable_uid(): - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - durable = Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="No meetings after 17:00", - description="Protect evenings.", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - hints={"uid": "tb:evening:1"}, - ) - local_decline = Constraint( - user_id="u1", - channel_id="c1", - thread_ts="t1", - name="No meetings after 17:00", - description="Protect evenings.", - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.DECLINED, - source=ConstraintSource.USER, - scope=ConstraintScope.SESSION, - hints={"uid": "tb:evening:1"}, - ) - - class _Store: - async def list_constraints(self, **_kwargs): - return [local_decline] - - agent._constraint_store = _Store() - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.durable_constraints_by_stage[TimeboxingStage.COLLECT_CONSTRAINTS.value] = [ - durable - ] - - combined = await agent._collect_constraints(session) - - assert "tb:evening:1" in session.suppressed_durable_uids - assert durable not in combined - assert combined == [] - assert session.active_constraints == [] - - -@pytest.mark.asyncio -async def test_profile_constraints_auto_upsert_to_durable_store(monkeypatch): - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - monkeypatch.setattr(settings, "notion_timeboxing_parent_page_id", "parent") - - agent._constraint_extraction_semaphore = asyncio.Semaphore(1) - agent._durable_constraint_semaphore = asyncio.Semaphore(1) - agent._durable_constraint_task_keys = set() - agent._constraint_extraction_tasks = {} - agent._durable_constraint_prefetch_tasks = {} - - captured_adds: list[list[ConstraintBase]] = [] - captured_upserts: list[dict] = [] - prefetch_reasons: list[str] = [] - - class _Store: - async def add_constraints(self, **kwargs): - captured_adds.append(list(kwargs["constraints"])) - return [] - - class _Client: - async def upsert_constraint(self, *, record: dict, event: dict | None = None): - captured_upserts.append({"record": record, "event": event}) - return {"uid": "tb_uid"} - - async def _fake_interpret(self, _session, *, text: str, is_initial: bool): - _ = (text, is_initial) - return ConstraintInterpretation( - should_extract=True, - scope="profile", - constraints=[ - ConstraintBase( - name="No calls after 17:00", - description="Avoid meetings after 17:00.", - necessity=ConstraintNecessity.SHOULD, - scope=ConstraintScope.PROFILE, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - tags=["meetings"], - ) - ], - ) - - async def _fake_collect(self, _session): - return [] - - async def _fake_ensure_store(self): - return None - - agent._constraint_store = _Store() - agent._ensure_constraint_store = types.MethodType(_fake_ensure_store, agent) - agent._interpret_constraints = types.MethodType(_fake_interpret, agent) - agent._collect_constraints = types.MethodType(_fake_collect, agent) - agent._ensure_constraint_memory_client = types.MethodType( - lambda _self: _Client(), agent - ) - agent._queue_durable_constraint_prefetch = types.MethodType( - lambda _self, *, session, reason: prefetch_reasons.append(reason), agent - ) - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", planned_date="2026-02-14") - task = agent._queue_constraint_extraction( - session=session, - text="In general, no calls after 5pm.", - reason="graphflow_turn", - is_initial=False, - ) - assert task is not None - await task - - # Wait for the background durable upsert task to flush. - for _ in range(50): - if not agent._durable_constraint_task_keys: - break - await asyncio.sleep(0.01) - - assert captured_adds, "Expected local session constraints to be persisted." - assert captured_upserts, "Expected durable Notion upsert to be attempted." - upsert_record = captured_upserts[0]["record"]["constraint_record"] - assert upsert_record["scope"] == "profile" - assert TimeboxingStage.COLLECT_CONSTRAINTS.value in upsert_record["applies_stages"] - assert "DW" in upsert_record["applies_event_types"] - assert "post_upsert" in prefetch_reasons - - -@pytest.mark.asyncio -async def test_await_pending_durable_prefetch_waits_for_task(): - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", planned_date="2026-02-14") - task_key = agent._durable_prefetch_stage_key( - session, stage=TimeboxingStage.COLLECT_CONSTRAINTS - ) - slow_task = asyncio.create_task(asyncio.sleep(0.05)) - agent._durable_constraint_prefetch_tasks = {task_key: slow_task} - agent._queue_durable_constraint_prefetch = types.MethodType( - lambda _self, **_kwargs: None, agent - ) - - await agent._await_pending_durable_constraint_prefetch( - session, - timeout_s=0.5, - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - ) - assert slow_task.done() is True - - -def _durable_sleep_constraint(*, uid: str = "tb:sleep:default") -> Constraint: - return Constraint( - user_id="u1", - channel_id=None, - thread_ts=None, - name="Sleep schedule", - description="Sleep at 23:00 and wake at 07:00.", - necessity=ConstraintNecessity.MUST, - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - tags=["sleep"], - hints={ - "uid": uid, - "rule_kind": "fixed_bedtime", - # aspect_classification required by _classify_collect_default_domain - # (refactored away from keyword scanning towards structured frame_slot). - "aspect_classification": { - "aspect_id": "sleep", - "aspect_label": "Sleep schedule", - "category": "recovery", - "frame_slot": "sleep_target", - "schedule_start": "23:00", - "schedule_end": "07:00", - }, - }, - ) - - -def test_collect_constraints_uses_durable_sleep_default_and_clears_sleep_missing() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.durable_constraints_by_stage[TimeboxingStage.COLLECT_CONSTRAINTS.value] = [ - _durable_sleep_constraint() - ] - - gate = StageGateOutput( - stage_id=TimeboxingStage.COLLECT_CONSTRAINTS, - ready=False, - summary=["Starting stage review."], - missing=["Sleep schedule or morning/evening routines"], - question="What are your sleep times?", - facts={}, - ) - normalized = agent._normalize_collect_constraints_gate( - session=session, - gate=gate, - user_message="", - ) - - assert normalized.ready is True - assert normalized.missing == [] - assert normalized.facts.get("sleep_target", {}).get("start") == "23:00" - assert normalized.facts.get("sleep_target", {}).get("end") == "07:00" - assert normalized.question == ( - "Using your saved defaults. Reply to override for this session, or proceed." - ) - assert any("Using your saved defaults:" in line for line in normalized.summary) - - -def test_collect_constraints_uses_local_profile_default_when_durable_empty() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-03-01", - ) - local_profile = _durable_sleep_constraint(uid="tb:sleep:local") - local_profile.thread_ts = "legacy-thread" - session.active_constraints = [local_profile] - - gate = StageGateOutput( - stage_id=TimeboxingStage.COLLECT_CONSTRAINTS, - ready=False, - summary=["Starting stage review."], - missing=["Sleep schedule or morning/evening routines"], - question="What are your sleep times?", - facts={}, - ) - normalized = agent._normalize_collect_constraints_gate( - session=session, - gate=gate, - user_message="", - ) - - assert normalized.facts.get("sleep_target", {}).get("start") == "23:00" - assert normalized.facts.get("sleep_target", {}).get("end") == "07:00" - assert normalized.question == ( - "Using your saved defaults. Reply to override for this session, or proceed." - ) - - -def test_collect_constraints_summary_mentions_calendar_anchors() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - gate = StageGateOutput( - stage_id=TimeboxingStage.COLLECT_CONSTRAINTS, - ready=False, - summary=["Starting stage review."], - missing=["sleep target"], - question="What should I lock in first?", - facts={ - "immovables": [ - {"title": "Planning Session", "start": "16:00", "end": "17:00"} - ] - }, - ) - - normalized = agent._normalize_collect_constraints_gate( - session=session, - gate=gate, - user_message="", - ) - - assert any( - line.startswith("Calendar anchors loaded:") - for line in (normalized.summary or []) - ) - - -def test_build_collect_constraints_context_merges_prefetched_anchors() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._session_debug = lambda *_args, **_kwargs: None - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-03-02", - tz_name="Europe/Amsterdam", - ) - session.frame_facts["immovables"] = [ - {"title": "Lunch", "start": "13:00", "end": "14:00"} - ] - session.prefetched_immovables_by_date["2026-03-02"] = [ - {"title": "Planning Session", "start": "16:00", "end": "17:00"} - ] - - context = agent._build_collect_constraints_context(session, user_message="") - - immovables = context["immovables"] - assert len(immovables) == 2 - assert immovables[0]["title"] == "Planning Session" - assert immovables[1]["title"] == "Lunch" - assert len(context["facts"]["immovables"]) == 2 - - -@pytest.mark.asyncio -async def test_collect_constraints_session_override_suppresses_durable_uid() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - durable = _durable_sleep_constraint(uid="tb:sleep:primary") - session.durable_constraints_by_stage[TimeboxingStage.COLLECT_CONSTRAINTS.value] = [durable] - agent._constraint_store = None - - gate = StageGateOutput( - stage_id=TimeboxingStage.COLLECT_CONSTRAINTS, - ready=False, - summary=["Captured user updates."], - missing=["sleep schedule"], - question="Confirm sleep schedule?", - facts={ - "sleep_target": { - "start": "00:00", - "end": "08:00", - "hours": 8.0, - } - }, - ) - - normalized = agent._normalize_collect_constraints_gate( - session=session, - gate=gate, - user_message="For tomorrow, sleep 00:00 to 08:00.", - ) - - assert "tb:sleep:primary" in session.suppressed_durable_uids - constraints = await agent._collect_constraints(session) - assert durable not in constraints - assert all( - (c.hints or {}).get("uid") != "tb:sleep:primary" - for c in session.active_constraints - ) - - context = agent._build_collect_constraints_context(session, user_message="") - assert context["durable_constraints"] == [] - assert normalized.facts["sleep_target"]["start"] == "00:00" - - -def test_collect_constraints_does_not_claim_no_durable_constraints_while_prefetch_pending() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.pending_durable_constraints = True - session.pending_durable_stages = {TimeboxingStage.COLLECT_CONSTRAINTS.value} - session.durable_constraints_loaded_stages = set() - - gate = StageGateOutput( - stage_id=TimeboxingStage.COLLECT_CONSTRAINTS, - ready=False, - summary=["No existing durable constraints found; we're starting with a clean canvas."], - missing=["sleep target"], - question="What time do you sleep?", - facts={}, - ) - - normalized = agent._normalize_collect_constraints_gate( - session=session, - gate=gate, - user_message="", - ) - - summary_text = "\n".join(normalized.summary) - assert "no existing durable constraints found" not in summary_text.lower() - assert "still loading" in summary_text.lower() - - -def test_durable_upsert_record_marks_startup_prefetch_for_sleep_defaults() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", planned_date="2026-02-18") - constraint = ConstraintBase( - name="Sleep schedule", - description="Sleep around 23:00 and wake around 07:00.", - necessity=ConstraintNecessity.MUST, - tags=["sleep"], - scope=ConstraintScope.PROFILE, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - ) - - record = agent._build_durable_constraint_record( - session=session, - constraint=constraint, - decision_scope="profile", - ) - - topics = record["constraint_record"]["topics"] - assert "sleep" in topics - assert STARTUP_PREFETCH_TAG in topics diff --git a/tests/unit/constraints/test_timeboxing_notion_constraint_extractor.py b/tests/unit/constraints/test_timeboxing_notion_constraint_extractor.py deleted file mode 100644 index a26b0b11..00000000 --- a/tests/unit/constraints/test_timeboxing_notion_constraint_extractor.py +++ /dev/null @@ -1,98 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace -from typing import Any - -import pytest - -from fateforger.agents.timeboxing import notion_constraint_extractor as extractor_mod - - -def _sample_payload() -> dict[str, Any]: - """Return a minimal valid `ConstraintExtractionOutput` payload.""" - return { - "constraint_record": { - "name": "No meetings before 10", - "description": "Avoid meetings before 10:00 on weekdays.", - "necessity": "should", - "status": "proposed", - "source": "user", - "scope": "profile", - "applicability": {"timezone": "Europe/Amsterdam"}, - "lifecycle": {"supersedes_uids": []}, - "payload": { - "rule_kind": "avoid_window", - "scalar_params": {}, - "windows": [ - { - "kind": "avoid", - "start_time_local": "08:00", - "end_time_local": "10:00", - } - ], - }, - "applies_stages": ["CollectConstraints"], - "applies_event_types": ["M"], - "topics": ["meetings"], - } - } - - -def test_build_constraint_extractor_agent_omits_output_content_type( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Ensure extractor agent is built without OpenAI parse-mode output typing.""" - captured_kwargs: dict[str, Any] = {} - - class _AssistantAgentStub: - def __init__(self, *args: Any, **kwargs: Any) -> None: - captured_kwargs.update(kwargs) - - monkeypatch.setattr(extractor_mod, "AssistantAgent", _AssistantAgentStub) - agent = extractor_mod.build_constraint_extractor_agent( - model_client=object(), tools=[] - ) - - assert isinstance(agent, _AssistantAgentStub) - assert "output_content_type" not in captured_kwargs - - -def test_parse_constraint_extraction_response_accepts_fenced_json() -> None: - """Ensure extractor parsing accepts JSON wrapped in markdown code fences.""" - payload = _sample_payload() - text = f"```json\n{extractor_mod.json.dumps(payload)}\n```" - response = SimpleNamespace( - chat_message=SimpleNamespace(content=text), - ) - - parsed = extractor_mod._parse_constraint_extraction_response(response) - - assert parsed.constraint_record.name == "No meetings before 10" - assert parsed.constraint_record.payload.rule_kind == "avoid_window" - - -@pytest.mark.asyncio -async def test_extract_and_upsert_raises_on_invalid_payload() -> None: - class _BadAgentTool: - async def run_json(self, *_args: Any, **_kwargs: Any): - return SimpleNamespace(chat_message=SimpleNamespace(content="not-json")) - - extractor = extractor_mod.NotionConstraintExtractor.__new__( - extractor_mod.NotionConstraintExtractor - ) - extractor._agent_tool = _BadAgentTool() - - handoff = extractor_mod.ConstraintHandoff( - planned_date=extractor_mod.date(2026, 2, 17), - timezone="Europe/Amsterdam", - stage_id="CollectConstraints", - user_utterance="No meetings before 10", - triggering_suggestion=None, - impacted_event_types=["M"], - suggested_tags=["meetings"], - session_id="s1", - decision_scope="profile", - ) - - with pytest.raises(RuntimeError, match="Constraint extractor returned invalid output"): - await extractor.extract_and_upsert(handoff) diff --git a/tests/unit/constraints/test_timeboxing_stage_message_constraint_context.py b/tests/unit/constraints/test_timeboxing_stage_message_constraint_context.py deleted file mode 100644 index b1d1ff9c..00000000 --- a/tests/unit/constraints/test_timeboxing_stage_message_constraint_context.py +++ /dev/null @@ -1,219 +0,0 @@ -from __future__ import annotations - -import pytest - -pytest.importorskip("autogen_agentchat") - -from fateforger.agents.timeboxing.agent import TimeboxingFlowAgent -from fateforger.agents.timeboxing.preferences import ( - Constraint, - ConstraintNecessity, - ConstraintScope, - ConstraintSource, - ConstraintStatus, -) -from fateforger.agents.timeboxing.stage_gating import ( - ConstraintsSection, - FreeformSection, - NextStepsSection, - SessionMessage, - StageGateOutput, - TimeboxingStage, -) - - -def _constraint( - name: str, - *, - source: ConstraintSource = ConstraintSource.USER, - status: ConstraintStatus = ConstraintStatus.LOCKED, -) -> Constraint: - return Constraint( - user_id="u1", - channel_id="c1", - thread_ts="t1", - name=name, - description=f"{name} description", - necessity=ConstraintNecessity.SHOULD, - scope=ConstraintScope.PROFILE, - source=source, - status=status, - ) - - -def test_stage_message_shows_current_step_question_and_plain_constraint_details() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - gate = StageGateOutput( - stage_id=TimeboxingStage.REFINE, - ready=True, - summary=["Patched the morning block."], - missing=[], - question="Anything else to adjust?", - facts={}, - ) - constraints = [_constraint(f"Constraint {idx}") for idx in range(1, 9)] - - message = agent._format_stage_message(gate=gate, constraints=constraints) - - assert "### Current step\nStage 4/5 (Refine)" in message - assert "Status: ready to proceed." in message - assert "### What I need from you" in message - assert "Anything else to adjust?" in message - assert "Use buttons below: Proceed, or Redo/Back/Cancel." in message - assert "### Constraints (top 3/8)" in message - assert "### All active constraints" in message - assert "
" not in message - assert "
" not in message - - -def test_stage_message_marks_assumptions_as_yes_state_with_deny_edit_hint() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - gate = StageGateOutput( - stage_id=TimeboxingStage.COLLECT_CONSTRAINTS, - ready=False, - summary=["Starting constraint collection."], - missing=["sleep target"], - question="What time do you want to sleep?", - facts={}, - ) - constraints = [ - _constraint("Lock bedtime", source=ConstraintSource.USER, status=ConstraintStatus.LOCKED), - _constraint( - "Assume no calls before 10", - source=ConstraintSource.SYSTEM, - status=ConstraintStatus.PROPOSED, - ), - ] - - message = agent._format_stage_message(gate=gate, constraints=constraints) - - assert "### Current step\nStage 1/5 (CollectConstraints)" in message - assert "Status: waiting on required input." in message - assert "### What I need from you" in message - assert "What time do you want to sleep?" in message - assert ( - "Use buttons below: after replying, click Redo (Back/Cancel also available)." - in message - ) - assert "### Assumptions currently applied (yes-state; deny/edit if wrong)" in message - assert "needs confirmation; deny/edit if wrong" in message - assert "### All active constraints" not in message - - -def test_stage_message_prefers_structured_section_payload_and_orders_sections() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - gate = StageGateOutput( - stage_id=TimeboxingStage.CAPTURE_INPUTS, - ready=False, - summary=["placeholder"], - missing=["placeholder"], - question="placeholder", - facts={}, - response_message=SessionMessage( - sections=[ - FreeformSection(heading="Context", content="Session context here."), - ConstraintsSection( - content=["Protect 09:00-12:00 focus"], - folded_content=["Protect 09:00-12:00 focus", "No calls after 18:00"], - ), - NextStepsSection(content=["Confirm deep-work block count", "Reply with a number."]), - ] - ), - ) - - message = agent._format_stage_message(gate=gate, constraints=[], immovables=[]) - - assert "### Current step\nStage 2/5 (CaptureInputs)" in message - assert "Status: waiting on required input." in message - assert "### What I need from you" in message - assert "### Constraints" in message - assert "### Context" in message - assert ( - "Use buttons below: after replying, click Redo (Back/Cancel also available)." - in message - ) - assert message.index("### Constraints") < message.index("### Context") - assert message.index("### Context") < message.index("### What I need from you") - - -def test_stage_message_moves_freeform_next_steps_to_end_and_strips_disclosure_tags() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - gate = StageGateOutput( - stage_id=TimeboxingStage.COLLECT_CONSTRAINTS, - ready=False, - summary=["placeholder"], - missing=["placeholder"], - question="placeholder", - facts={}, - response_message=SessionMessage( - sections=[ - FreeformSection(heading="Anchors", content="Day: Saturday, February 28, 2026"), - FreeformSection( - heading="What I need from you", - content=( - "
\n" - "Show all constraints\n" - "Constraint searching: completed.\n" - "
\n" - "Confirm your wake-up time." - ), - ), - FreeformSection(heading="Greetings", content="Good afternoon."), - ] - ), - ) - - message = agent._format_stage_message(gate=gate, constraints=[], immovables=[]) - - assert "
" not in message - assert "
" not in message - assert "" not in message - assert "" not in message - assert "Show all constraints" in message - assert message.index("### Greetings") < message.index("### What I need from you") - - -def test_stage_message_compacts_folded_constraints_for_slack_size() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - folded = [ - f"Constraint {idx}: enforce long detail text to simulate large payload volume." - for idx in range(1, 180) - ] - gate = StageGateOutput( - stage_id=TimeboxingStage.CAPTURE_INPUTS, - ready=True, - summary=["constraints snapshot"], - missing=[], - question="continue?", - facts={}, - response_message=SessionMessage( - sections=[ - ConstraintsSection(content=["Top constraint"], folded_content=folded), - NextStepsSection(content=["Proceed."]), - ] - ), - ) - - message = agent._format_stage_message(gate=gate, constraints=[], immovables=[]) - - assert "### All active constraints" in message - assert "open full list to review" in message - assert len(message) < 4500 - - -def test_review_commit_template_includes_submit_button_guidance() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - gate = StageGateOutput( - stage_id=TimeboxingStage.REVIEW_COMMIT, - ready=True, - summary=["Final review complete."], - missing=[], - question="Ready to submit?", - facts={}, - ) - - message = agent._format_stage_message(gate=gate, constraints=[], immovables=[]) - - assert "### Current step\nStage 5/5 (ReviewCommit)" in message - assert "Status: ready to proceed." in message - assert "Use buttons below: Submit to Calendar or Keep Editing." in message diff --git a/tests/unit/core/test_handoff_policy.py b/tests/unit/core/test_handoff_policy.py deleted file mode 100644 index 76fb2754..00000000 --- a/tests/unit/core/test_handoff_policy.py +++ /dev/null @@ -1,34 +0,0 @@ -from __future__ import annotations - -from fateforger.agents.shared.handoff_policy import ( - HandoffIntent, - HandoffPolicy, - HandoffRoute, -) - - -def test_handoff_policy_requires_explicit_target_and_confidence() -> None: - policy = HandoffPolicy(allowed_targets={"tasks_agent"}, min_confidence=0.8) - - assert ( - policy.resolve( - HandoffIntent(action="assist", target="tasks_agent", confidence=0.95) - ) - == HandoffRoute.HANDOFF - ) - assert ( - policy.resolve( - HandoffIntent(action="assist", target="tasks_agent", confidence=0.4) - ) - == HandoffRoute.STAY_CURRENT - ) - assert ( - policy.resolve(HandoffIntent(action="assist", target=None, confidence=0.95)) - == HandoffRoute.STAY_CURRENT - ) - assert ( - policy.resolve( - HandoffIntent(action="provide_info", target="tasks_agent", confidence=1.0) - ) - == HandoffRoute.STAY_CURRENT - ) diff --git a/tests/unit/core/test_sync_submit_baseline_guard.py b/tests/unit/core/test_sync_submit_baseline_guard.py deleted file mode 100644 index 450a6bb5..00000000 --- a/tests/unit/core/test_sync_submit_baseline_guard.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Unit tests for shared submit baseline guard contract.""" - -from fateforger.sync_core.submit_baseline_guard import ( - SubmitBaselineGuardReason, - evaluate_submit_baseline_guard, -) - - -def test_evaluate_submit_baseline_guard_ready() -> None: - result = evaluate_submit_baseline_guard( - refresh_ok=True, - has_base_snapshot=True, - ) - assert result.ready is True - assert result.reason == "ready" - - -def test_evaluate_submit_baseline_guard_refresh_failure() -> None: - result = evaluate_submit_baseline_guard( - refresh_ok=False, - has_base_snapshot=True, - ) - assert result.ready is False - assert result.reason == "remote_baseline_refresh_failed" - - -def test_evaluate_submit_baseline_guard_missing_base_snapshot() -> None: - result = evaluate_submit_baseline_guard( - refresh_ok=True, - has_base_snapshot=False, - ) - assert result.ready is False - assert result.reason == "missing_base_snapshot" - - -def test_submit_baseline_guard_reason_type_alias_contains_expected_values() -> None: - values: set[SubmitBaselineGuardReason] = { - "ready", - "remote_baseline_refresh_failed", - "missing_base_snapshot", - } - assert len(values) == 3 diff --git a/tests/unit/core/test_tests_reach_subjects_honestly.py b/tests/unit/core/test_tests_reach_subjects_honestly.py new file mode 100644 index 00000000..c3f69cc4 --- /dev/null +++ b/tests/unit/core/test_tests_reach_subjects_honestly.py @@ -0,0 +1,174 @@ +"""A test reaches its subject honestly, or says why it cannot. + +Two shapes are refused across ``tests/``: ``Class.__new__(Class)``, which +builds an object the constructor would have refused, and ``obj._x = ...`` +on any target that is not ``self``, which reaches past a public interface -- +on the subject or on a double (a double's state belongs in its ``__init__``). +The private-write shape is judged the same way regardless of how the +assignment is spelled: plain ``obj._x = 1``, tuple/list-unpacking +(``obj._x, obj._y = a, b``, including a starred target), augmented +(``obj._x += 1``), and annotated (``obj._x: int = 1``) all walk through the +same target-judging code, because a guard that only watches the common +spelling is a guard the next dishonest reach just writes around. + +``tests/honest_allowlist.py`` lists, per file, how many such sites it still +carries and why. The count is a ceiling, not a tally to hit: a file may fall +below it (fix a site, and the ratchet test says to lower the number) but +never rise above it. Line numbers move when a file is edited, so the +allowance is keyed by file and by count, not by line -- an edit that shifts +lines without adding or removing an offence changes nothing here. + +Each offence is keyed by its own target's ``lineno:col_offset``, not by the +statement's line alone -- two dishonest targets can share one line +(``obj._x, obj._y = a, b``; ``a._x = b._y = 1``), and a guard that folds them +into one entry can't see a second write added beside an already-flagged one. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +from tests.honest_allowlist import ALLOWED +from tests.repo import ROOT + +TESTS = ROOT / "tests" + + +def _attribute_targets(target: ast.expr) -> list[ast.Attribute]: + """Every ``ast.Attribute`` an assignment target reaches, however nested. + + A plain target is itself. A tuple/list target (unpacking) contributes + each of its elements. A starred target contributes what it stars. + Anything else (a bare name, a subscript, ...) contributes nothing -- + it cannot be a private-attribute write. + """ + if isinstance(target, ast.Attribute): + return [target] + if isinstance(target, (ast.Tuple, ast.List)): + found: list[ast.Attribute] = [] + for elt in target.elts: + found.extend(_attribute_targets(elt)) + return found + if isinstance(target, ast.Starred): + return _attribute_targets(target.value) + return [] + + +def _is_dishonest_write(target: ast.Attribute) -> bool: + return ( + target.attr.startswith("_") + and not target.attr.startswith("__") + and not (isinstance(target.value, ast.Name) and target.value.id == "self") + ) + + +def _offences_in(tree: ast.AST, rel: str) -> dict[str, str]: + found: dict[str, str] = {} + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "__new__" + ): + found[f"{rel}::{node.lineno}:{node.col_offset}"] = "__new__" + if isinstance(node, ast.Assign): + for raw_target in node.targets: + for target in _attribute_targets(raw_target): + if _is_dishonest_write(target): + key = f"{rel}::{target.lineno}:{target.col_offset}" + found[key] = f"private write {ast.unparse(target)}" + if isinstance(node, (ast.AugAssign, ast.AnnAssign)): + for target in _attribute_targets(node.target): + if _is_dishonest_write(target): + key = f"{rel}::{target.lineno}:{target.col_offset}" + found[key] = f"private write {ast.unparse(target)}" + return found + + +def _offences() -> dict[str, str]: + found: dict[str, str] = {} + for path in sorted(TESTS.rglob("*.py")): + if path.name in {"honest_allowlist.py"} or path == Path(__file__): + continue + tree = ast.parse(path.read_text()) + rel = path.relative_to(ROOT).as_posix() + found.update(_offences_in(tree, rel)) + return found + + +def _by_file(offences: dict[str, str]) -> dict[str, list[tuple[int, int, str]]]: + grouped: dict[str, list[tuple[int, int, str]]] = {} + for key, kind in offences.items(): + path, _, pos = key.rpartition("::") + line, _, col = pos.partition(":") + grouped.setdefault(path, []).append((int(line), int(col), kind)) + return grouped + + +def test_every_dishonest_reach_is_allowlisted_with_a_reason(): + grouped = _by_file(_offences()) + problems: list[str] = [] + for path, sites in sorted(grouped.items()): + allowance = ALLOWED.get(path) + sites_desc = ", ".join(f"{line}:{col} ({kind})" for line, col, kind in sorted(sites)) + if allowance is None: + problems.append(f"{path}: not allowlisted -- {sites_desc}") + continue + count, _reason = allowance + if len(sites) > count: + problems.append( + f"{path}: {len(sites)} offences exceed its allowance of {count} -- {sites_desc}" + ) + assert not problems, "\n".join(problems) + + +def test_the_allowlist_is_a_ratchet(): + grouped = _by_file(_offences()) + problems: list[str] = [] + for path, (count, _reason) in sorted(ALLOWED.items()): + actual = len(grouped.get(path, [])) + if actual == 0: + problems.append(f"{path}: carries no offences any more -- remove its entry") + elif actual < count: + problems.append( + f"{path}: allowance is {count} but only {actual} remain -- lower it" + ) + assert not problems, "\n".join(problems) + + +def test_every_allowlist_entry_says_why(): + assert all(reason.strip() for _count, reason in ALLOWED.values()) + + +def test_the_walker_sees_every_assignment_shape(): + source = """ +def f(obj, self, p, q, r, a, b, plain): + obj._x, obj._y = 1, 2 + (p._a, (q._b, r._c)) = 1, (2, 3) + obj._n += 1 + obj._t: int = 1 + plain._z = 1 + self._ok = 1 + a._x = b._y = 1 +""" + tree = ast.parse(source) + offences = _offences_in(tree, "synthetic.py") + + private_writes = {k: v for k, v in offences.items() if v.startswith("private write")} + reached = {v.removeprefix("private write ") for v in private_writes.values()} + + assert len(private_writes) == 10, private_writes + assert reached == { + "obj._x", + "obj._y", + "p._a", + "q._b", + "r._c", + "obj._n", + "obj._t", + "plain._z", + "a._x", + "b._y", + } + assert not any("self._ok" in v for v in private_writes.values()) diff --git a/tests/unit/core/test_toon_encode.py b/tests/unit/core/test_toon_encode.py deleted file mode 100644 index 3393f3c7..00000000 --- a/tests/unit/core/test_toon_encode.py +++ /dev/null @@ -1,25 +0,0 @@ -from __future__ import annotations - -from fateforger.llm.toon import toon_encode - - -def test_toon_encode_emits_header_and_rows() -> None: - out = toon_encode( - name="users", - rows=[{"id": 1, "name": "Alice", "role": "admin"}, {"id": 2, "name": "Bob", "role": "user"}], - fields=["id", "name", "role"], - ) - assert out.startswith("users[2]{id,name,role}:") - assert "1,Alice,admin" in out - assert "2,Bob,user" in out - - -def test_toon_encode_quotes_commas() -> None: - out = toon_encode( - name="items", - rows=[{"name": "Hello, world", "note": "x"}], - fields=["name", "note"], - ) - assert 'items[1]{name,note}:' in out - assert '"Hello, world",x' in out - diff --git a/tests/unit/slack/test_retired_cards.py b/tests/unit/slack/test_retired_cards.py new file mode 100644 index 00000000..f59c44c0 --- /dev/null +++ b/tests/unit/slack/test_retired_cards.py @@ -0,0 +1,54 @@ +"""A button from a retired flow says so, instead of failing forever. + +The legacy agent's confirm/undo and stage cards can still be in Slack history. +Their old dispatchers sent to an agent that no longer exists inside a broad +``except`` that rewrote the card to "please try again" -- a missing agent +would have read as a transient glitch, every time, for as long as the card +existed. +""" + +from __future__ import annotations + +import pytest + +from fateforger.slack_bot import retired_cards +from tests.doubles.slack import RecordingSlackClient + + +@pytest.mark.parametrize("action_id", retired_cards.RETIRED_ACTION_IDS) +async def test_pressing_a_retired_card_rewrites_it_in_place(action_id): + client = RecordingSlackClient() + body = { + "channel": {"id": "C1"}, + "message": {"ts": "100.1"}, + "actions": [{"action_id": action_id, "value": "anything"}], + } + + await retired_cards.retire_card(client=client, body=body) + + assert len(client.updates) == 1 + update = client.updates[0] + assert (update["channel"], update["ts"]) == ("C1", "100.1") + assert update["text"] == retired_cards.RETIRED_CARD_TEXT + assert update["blocks"][0]["text"]["text"] == retired_cards.RETIRED_CARD_TEXT + + +async def test_a_press_without_a_message_is_ignored_not_raised(): + client = RecordingSlackClient() + await retired_cards.retire_card(client=client, body={"actions": [{}]}) + assert client.updates == [] + + +def test_the_ten_legacy_action_ids_are_all_covered(): + assert set(retired_cards.RETIRED_ACTION_IDS) == { + "ff_timebox_confirm_submit", + "ff_timebox_cancel_submit", + "ff_timebox_undo_submit", + "ff_timebox_stage_proceed", + "ff_timebox_stage_back", + "ff_timebox_stage_redo", + "ff_timebox_stage_cancel", + "timeboxing_constraint_review", + "ff_timeboxing_constraint_review_all", + "timeboxing_constraint_review_all", + } diff --git a/tests/unit/slack/test_schedular_routes_to_harness.py b/tests/unit/slack/test_schedular_routes_to_harness.py index affaaf9b..96ac431c 100644 --- a/tests/unit/slack/test_schedular_routes_to_harness.py +++ b/tests/unit/slack/test_schedular_routes_to_harness.py @@ -1,16 +1,18 @@ -"""The Schedular's thread messages go to the harness, not the AutoGen flow. +"""Every door into timeboxing reaches the kernel, keyed by its own thread. -Same persona, same thread, different brain. The legacy flow reached a -constraint store that spent months reading a Notion page returning 404, so it -planned while knowing nothing it had ever been told. +A message can resolve to timeboxing two ways -- the up-front route (`/timebox`, +or a thread already bound to it) and the receptionist's handoff, resolved +inside the AutoGen runtime. Both must land in `_run_adaptive_timebox_turn`, and +each must key its session off the thread the user is actually in: a session +filed under a thread nobody continues in rehydrates as somebody else's day. + +The two smaller tests below guard the progress card's loop and the thread the +approval card is posted into, both of which the kernel path still owns. """ from __future__ import annotations import asyncio -import threading - -import pytest from fateforger.slack_bot import handlers from fateforger.slack_bot.dsh_progress_hook import ( @@ -19,255 +21,6 @@ ProgressStatus, ) from fateforger.slack_bot.progress import HarnessProgressCard -from fateforger.slack_bot.timebox_candidate import ValidatedTimeboxCandidate - - -class _Reply: - """Mirrors `HarnessReply`'s shape, because the handler reads its fields. - - Kept in step deliberately rather than letting the handler use `getattr` - with a default: a stub that silently tolerates a missing field is a stub - that stops testing the contract the moment the contract grows. - """ - - def __init__(self, text: str, *, needs_approval: bool = False) -> None: - self.text = text - self.timings = None - self.committed_tx_id = None - self.needs_approval = needs_approval - self.validated_candidate = None - - -@pytest.fixture -def _harness(monkeypatch): - """Stub the subprocess. Reaching a real harness in a unit test is a bug.""" - calls: list[dict] = [] - - def fake_ask(text, *, on_event=None, **kw): - calls.append({"text": text, "on_event": on_event}) - if on_event: - on_event("mcp__memory__memory_get_active_constraints") - return _Reply("Here is tomorrow.") - - import fateforger.slack_bot.harness_bridge as hb - - monkeypatch.setattr(hb, "ask", fake_ask) - return calls - - -async def test_a_turn_reaches_the_harness_and_comes_back_renderable(_harness): - """The reply must be shaped like a runtime reply, or every renderer breaks.""" - result = await handlers._harness_turn( - text="plan tomorrow", - thread_key="C1:1772.0", - owner_user_id="U1", - on_phase=lambda _l: None, - ) - assert result.content == "Here is tomorrow." - assert result.source == "timeboxing_agent" - assert _harness[0]["text"] == "plan tomorrow" - - -async def test_progress_is_offered_so_a_long_turn_is_not_a_blank_wait(_harness): - seen: list[str] = [] - await handlers._harness_turn( - text="plan tomorrow", - thread_key="C1:1772.0", - owner_user_id="U1", - on_phase=seen.append, - ) - assert seen == ["mcp__memory__memory_get_active_constraints"] - - -async def test_a_harness_failure_is_surfaced_not_swallowed(monkeypatch): - """A harness that could not be reached and a planner that declined to act - must not read the same in the thread.""" - import fateforger.slack_bot.harness_bridge as hb - - def boom(text, **kw): - raise hb.HarnessError("node not found") - - monkeypatch.setattr(hb, "ask", boom) - - result = await handlers._harness_turn( - text="plan tomorrow", - thread_key="C1:1772.0", - owner_user_id="U1", - on_phase=lambda _l: None, - ) - assert "did not answer" in result.content - assert "node not found" in result.content - - -async def test_clean_candidate_is_offered_for_approval_without_model_commit_attempt( - monkeypatch, -): - """Obeying 'do not commit' must not make a validated proposal unapprovable.""" - - import fateforger.slack_bot.harness_bridge as hb - - candidate = ValidatedTimeboxCandidate( - digest="a" * 64, - snapshot={"day": "2026-08-30"}, - patch={"ops": []}, - rendered="canonical plan", - ) - - def fake_ask(text, **_kwargs): - reply = _Reply("canonical plan", needs_approval=False) - reply.validated_candidate = candidate - return reply - - monkeypatch.setattr(hb, "ask", fake_ask) - - await handlers._harness_turn( - text="show only", - thread_key="C1:no-commit", - owner_user_id="U1", - on_phase=lambda _event: None, - ) - - assert handlers.take_pending_approval("C1:no-commit") - - -async def test_a_new_turn_supersedes_and_cancels_the_prior_owned_child(monkeypatch): - """A retry in one Slack thread must stop the old process before replacing it.""" - - import fateforger.slack_bot.harness_bridge as hb - - first_started = threading.Event() - first_cancelled = threading.Event() - - def fake_ask(text, *, cancel_event=None, **_kwargs): - if text == "first": - first_started.set() - if cancel_event is None or not cancel_event.wait(timeout=1.0): - raise AssertionError("the prior turn never received cancellation") - first_cancelled.set() - raise hb.HarnessCancelled("superseded") - return _Reply("second answer") - - monkeypatch.setattr(hb, "ask", fake_ask) - first_phases: list[object] = [] - first = asyncio.create_task( - handlers._harness_turn( - text="first", - thread_key="C1:1772.0", - owner_user_id="U1", - on_phase=first_phases.append, - ) - ) - assert await asyncio.to_thread(first_started.wait, 1.0) - - second = await handlers._harness_turn( - text="second", - thread_key="C1:1772.0", - owner_user_id="U1", - on_phase=lambda _line: None, - ) - with pytest.raises(asyncio.CancelledError): - await first - - assert second.content == "second answer" - assert first_cancelled.is_set() - assert len(first_phases) == 1 - assert first_phases[0].status.value == "superseded" - - -async def test_replacement_cannot_enter_harness_until_superseded_turn_exits( - monkeypatch, -): - """Cancellation is not reaping: replacement starts only after old exit.""" - - import fateforger.slack_bot.harness_bridge as hb - - first_started = threading.Event() - release_first = threading.Event() - call_order: list[str] = [] - - def fake_ask(text, *, cancel_event=None, **_kwargs): - call_order.append(f"enter:{text}") - if text == "first": - first_started.set() - assert cancel_event is not None - assert cancel_event.wait(timeout=1.0) - assert release_first.wait(timeout=1.0) - call_order.append("exit:first") - raise hb.HarnessCancelled("superseded") - call_order.append("exit:second") - return _Reply("second answer") - - monkeypatch.setattr(hb, "ask", fake_ask) - first = asyncio.create_task( - handlers._harness_turn( - text="first", - thread_key="C1:ordered", - owner_user_id="U1", - on_phase=lambda _event: None, - ) - ) - assert await asyncio.to_thread(first_started.wait, 1.0) - second = asyncio.create_task( - handlers._harness_turn( - text="second", - thread_key="C1:ordered", - owner_user_id="U1", - on_phase=lambda _event: None, - ) - ) - await asyncio.sleep(0.05) - assert call_order == ["enter:first"] - - release_first.set() - result = await second - with pytest.raises(asyncio.CancelledError): - await first - - assert result.content == "second answer" - assert call_order == ["enter:first", "exit:first", "enter:second", "exit:second"] - - -async def test_harness_launch_waits_for_an_inflight_exact_candidate_commit(monkeypatch): - """A new draft cannot race a calendar write whose outcome is still unknown.""" - - import fateforger.slack_bot.harness_bridge as hb - - entered = threading.Event() - - def fake_ask(text, **_kwargs): - entered.set() - return _Reply("answer") - - monkeypatch.setattr(hb, "ask", fake_ask) - commit_lock = handlers._thread_lock(handlers._thread_commit_locks, "C1:fence") - await commit_lock.acquire() - try: - turn = asyncio.create_task( - handlers._owned_harness_ask( - "next plan", - thread_key="C1:fence", - on_event=lambda _event: None, - ) - ) - await asyncio.sleep(0.05) - assert not entered.is_set() - finally: - commit_lock.release() - - await turn - assert entered.is_set() - - -def test_the_legacy_flow_is_still_reachable(monkeypatch): - """A migration nobody can reverse is a rewrite. - - The legacy path is the only one carrying the five-stage machine and the - confirm buttons, so it stays wired until the harness has an equivalent. - """ - monkeypatch.setenv("FF_TIMEBOX_BACKEND", "legacy") - assert handlers._timebox_backend() == "legacy" - monkeypatch.setenv("FF_TIMEBOX_BACKEND", "harness") - assert handlers._timebox_backend() != "legacy" async def test_a_phase_line_from_the_poller_thread_reaches_slack(): @@ -412,7 +165,9 @@ def test_the_handoff_interception_uses_the_redirected_thread(): under a thread nobody continues in, and a misfiled session rehydrates as an empty one -- and the direct in-thread continuation later in the function, which never redirects and so must key off its own - ``recipient_key``. + ``recipient_key``. The redirect route is the third site: a redirected + thread is an open session, so it continues on the kernel keyed by + ``redirect.target_key``, never by a send. This used to be one call, found with ``rindex`` on the assumption that the handoff's call was always textually last. That broke silently when @@ -465,8 +220,8 @@ def _session_key_source(call): for call in _calls_to(route_def, "_run_adaptive_timebox_turn") if call.lineno not in surface_span ] - assert len(direct_calls) == 1, direct_calls - assert _session_key_source(direct_calls[0]) == "recipient_key" + outside = sorted(_session_key_source(call) for call in direct_calls) + assert outside == ["recipient_key", "redirect.target_key"], outside def test_approval_card_stays_in_the_plan_thread_for_top_level_requests(): diff --git a/tests/unit/slack/test_slack_boundary_withholds_tool_results.py b/tests/unit/slack/test_slack_boundary_withholds_tool_results.py index cc1ab99c..051a23f0 100644 --- a/tests/unit/slack/test_slack_boundary_withholds_tool_results.py +++ b/tests/unit/slack/test_slack_boundary_withholds_tool_results.py @@ -20,15 +20,6 @@ from fateforger.slack_bot.handlers import _slack_payload_from_result from fateforger.slack_bot.messages import SlackBlockMessage, SlackThreadStateMessage -from fateforger.slack_bot.timeboxing_commit import ( - _slack_payload_from_result as _commit_payload, -) -from fateforger.slack_bot.timeboxing_stage_actions import ( - _slack_payload_from_result as _stage_payload, -) -from fateforger.slack_bot.timeboxing_submit import ( - _slack_payload_from_result as _submit_payload, -) # The shape that actually reached Slack: autogen's MCP adapter json.dumps()es the @@ -153,13 +144,14 @@ def test_absent_content_still_reads_as_no_response(): assert _slack_payload_from_result(_Response(None)) == {"text": "(no response)"} -# Four modules build Slack payloads from agent results. All of them ended in the same two lines -# before #180, so all of them could post the same leak; they now share one guard, and this -# parametrisation is what keeps a future fifth copy from drifting back. +# Four modules built Slack payloads from agent results before #180, and all of them ended in +# the same two lines, so all of them could post the same leak. Three of the four copies left +# with the legacy dispatchers that owned them once those retired; the parametrisation stays on +# the one that remains so a second copy is caught if one is ever added back. @pytest.mark.parametrize( "build_payload", - [_slack_payload_from_result, _commit_payload, _stage_payload, _submit_payload], - ids=["handlers", "timeboxing_commit", "timeboxing_stage_actions", "timeboxing_submit"], + [_slack_payload_from_result], + ids=["handlers"], ) def test_every_slack_payload_builder_withholds_tool_results(build_payload): withheld = build_payload(_Response(_tool_summary(LEAKED_PAYLOAD)))["text"] diff --git a/tests/unit/slack/test_slack_timeboxing_surface.py b/tests/unit/slack/test_slack_timeboxing_surface.py index 550c40e3..48116dea 100644 --- a/tests/unit/slack/test_slack_timeboxing_surface.py +++ b/tests/unit/slack/test_slack_timeboxing_surface.py @@ -78,11 +78,9 @@ async def test_timeboxing_handoff_does_not_redirect_from_dm(monkeypatch): client=client, ) - assert [r.type for _, r in runtime.calls] == ["receptionist_agent", "timeboxing_agent"] + assert [r.type for _, r in runtime.calls] == ["receptionist_agent"] # Timeboxing always anchors the session in #timeboxing (even when initiated via DM) - assert runtime.calls[1][1].key == "C_TIMEBOX:dm_root" assert any(p.get("channel") == "C_TIMEBOX" and not p.get("thread_ts") for p in client.posted) - assert any(p.get("channel") == "C_TIMEBOX" and p.get("thread_ts") == "dm_root" for p in client.posted) # ── recovering focus ────────────────────────────────────────────────────────── diff --git a/tests/unit/slack/test_timebox_backend_routing.py b/tests/unit/slack/test_timebox_backend_routing.py deleted file mode 100644 index c2bbaca8..00000000 --- a/tests/unit/slack/test_timebox_backend_routing.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Which backend /timebox routes to. What the harness is handed lives in -test_timebox_bare_command.py.""" - -from __future__ import annotations - -import os - -import pytest - -from fateforger.slack_bot.handlers import _timebox_backend - - -@pytest.fixture(autouse=True) -def _clean_env(): - saved = os.environ.pop("FF_TIMEBOX_BACKEND", None) - yield - if saved is not None: - os.environ["FF_TIMEBOX_BACKEND"] = saved - else: - os.environ.pop("FF_TIMEBOX_BACKEND", None) - - -def test_the_harness_answers_by_default(): - assert _timebox_backend() == "harness" - - -def test_the_legacy_flow_stays_one_variable_away(): - """A migration nobody can reverse is a rewrite. - - The legacy path is still the only one with the five-stage machine. - """ - os.environ["FF_TIMEBOX_BACKEND"] = "legacy" - assert _timebox_backend() == "legacy" - - -def test_the_flag_is_read_per_call_not_captured_at_import(): - """Flipping it must not require a restart to observe.""" - assert _timebox_backend() == "harness" - os.environ["FF_TIMEBOX_BACKEND"] = "legacy" - assert _timebox_backend() == "legacy" - os.environ["FF_TIMEBOX_BACKEND"] = "harness" - assert _timebox_backend() == "harness" - - -def test_an_unrecognised_value_does_not_silently_mean_legacy(): - """Only "legacy" routes away from the harness. - - A typo must not quietly restore the system being migrated off, which is - the sort of thing nobody notices until the behaviour they were testing - turns out to be the old one. - """ - os.environ["FF_TIMEBOX_BACKEND"] = "lgacy" - assert _timebox_backend() != "legacy" diff --git a/tests/unit/slack/test_timebox_session_surface.py b/tests/unit/slack/test_timebox_session_surface.py index 13fb48cb..e46f5411 100644 --- a/tests/unit/slack/test_timebox_session_surface.py +++ b/tests/unit/slack/test_timebox_session_surface.py @@ -74,19 +74,6 @@ def _writes_to(client: _FakeClient, ts: str) -> list[dict]: return born + edits -@pytest.fixture(autouse=True) -def _harness_backend(monkeypatch): - """This suite exercises the harness surface specifically. - - tests/conftest.py pins every test to `FF_TIMEBOX_BACKEND=legacy` by - default so route_slack_event never shells out unasked; the session - surface under test only exists on the harness path, so reaching it here - is deliberate, same as test_slack_timeboxing_channel_redirect.py and - test_harness_approval_action.py already do for the same reason. - """ - monkeypatch.setenv("FF_TIMEBOX_BACKEND", "harness") - - @pytest.fixture def focus() -> FocusManager: return FocusManager( diff --git a/tests/unit/slack/test_timeboxing_is_never_addressed_on_the_runtime.py b/tests/unit/slack/test_timeboxing_is_never_addressed_on_the_runtime.py new file mode 100644 index 00000000..f3015e94 --- /dev/null +++ b/tests/unit/slack/test_timeboxing_is_never_addressed_on_the_runtime.py @@ -0,0 +1,147 @@ +"""Timeboxing has one destination: the adaptive kernel's session surface. + +A handoff to ``timeboxing_agent`` is a string the receptionist returns; Slack +code reads it and opens a session. Nothing should ever hand the AutoGen +runtime an ``AgentId("timeboxing_agent", ...)``: once the class is retired +that raises a bare ``Exception("Recipient not found")`` from +``SingleThreadedAgentRuntime.send_message`` and the user sees a warning that +looks like a transport failure. + +The fake runtime here answers the receptionist and raises for anything else, +which is exactly what the real runtime does for an unregistered type. +""" + +from __future__ import annotations + +import ast +import inspect +import types + +import pytest + +from autogen_agentchat.messages import HandoffMessage + +from fateforger.core import runtime as runtime_module +from fateforger.core.config import settings +from fateforger.slack_bot import handlers +from fateforger.slack_bot.focus import FocusManager +from fateforger.slack_bot.timeboxing_cards import timebox_failure_message +from tests.doubles.slack import RecordingSlackClient + +RETIRING = {"timeboxing_agent"} + + +def _registered_agent_types() -> set[str]: + """Every ``X.register(runtime, "", ...)`` in runtime.py, by AST.""" + tree = ast.parse(inspect.getsource(runtime_module)) + names: set[str] = set() + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "register" + and len(node.args) >= 2 + and isinstance(node.args[1], ast.Constant) + and isinstance(node.args[1].value, str) + ): + names.add(node.args[1].value) + assert "receptionist_agent" in names, names + return names + + +class _RealShapedRuntime: + """Answers the receptionist with a handoff; raises for a retired type.""" + + def __init__(self) -> None: + self.addressed: list[str] = [] + self._known = _registered_agent_types() - RETIRING + + async def send_message(self, message, recipient): + self.addressed.append(recipient.type) + if recipient.type not in self._known: + raise Exception("Recipient not found") + return types.SimpleNamespace( + chat_message=HandoffMessage( + target="timeboxing_agent", content="handoff", source="receptionist_agent" + ) + ) + + +@pytest.fixture +def harness_turns(monkeypatch): + """Record every kernel turn instead of running one; a fake runtime has no kernel.""" + turns: list[dict] = [] + + async def _turn(**kwargs): + turns.append(kwargs) + return timebox_failure_message() + + monkeypatch.setattr(handlers, "_run_adaptive_timebox_turn", _turn) + return turns + + +async def _say(**_kw): + return {"channel": "C_ORIG", "ts": "say"} + + +async def _route(*, runtime, focus, client, event): + await handlers.route_slack_event( + runtime=runtime, + focus=focus, + default_agent="receptionist_agent", + event=event, + bot_user_id=None, + say=_say, + client=client, + ) + + +def _focus() -> FocusManager: + return FocusManager( + ttl_seconds=3600, allowed_agents=["receptionist_agent", "timeboxing_agent"] + ) + + +@pytest.mark.parametrize( + ("configured_channel", "event"), + [ + ("C_TIMEBOX", {"channel": "C_ORIG", "user": "U1", "text": "timebox tomorrow", "ts": "1"}), + ("C_TIMEBOX", {"channel": "D_DM", "channel_type": "im", "user": "U1", "text": "timebox tomorrow", "ts": "1"}), + ("", {"channel": "C_ORIG", "user": "U1", "text": "timebox tomorrow", "ts": "1"}), + ("C_ORIG", {"channel": "C_ORIG", "user": "U1", "text": "timebox tomorrow", "ts": "1"}), + ], + ids=["channel-configured", "from-dm", "no-channel-configured", "already-in-the-channel"], +) +async def test_a_handoff_never_addresses_the_retired_agent( + monkeypatch, harness_turns, configured_channel, event +): + monkeypatch.setattr(settings, "slack_timeboxing_channel_id", configured_channel, raising=False) + runtime = _RealShapedRuntime() + client = RecordingSlackClient(root_ts="tb_root", reply_ts="tb_proc", dm_channel="D_DM") + + await _route(runtime=runtime, focus=_focus(), client=client, event=event) + + assert "timeboxing_agent" not in runtime.addressed, runtime.addressed + assert len(harness_turns) == 1, "the handoff must reach the kernel exactly once" + session_channel = configured_channel or event["channel"] + assert harness_turns[0]["session_key"].startswith(f"{session_channel}:") + + +async def test_a_second_dm_turn_continues_the_session_on_the_kernel(monkeypatch, harness_turns): + """The redirect route: once a session is open, the next DM message takes it.""" + monkeypatch.setattr(settings, "slack_timeboxing_channel_id", "C_TIMEBOX", raising=False) + runtime = _RealShapedRuntime() + client = RecordingSlackClient(root_ts="tb_root", reply_ts="tb_proc", dm_channel="D_DM") + focus = _focus() + dm = {"channel": "D_DM", "channel_type": "im", "user": "U1"} + + await _route(runtime=runtime, focus=focus, client=client, event={**dm, "text": "timebox tomorrow", "ts": "1"}) + await _route(runtime=runtime, focus=focus, client=client, event={**dm, "text": "move gym later", "ts": "2"}) + + assert runtime.addressed == ["receptionist_agent"], "the second turn must not go to the runtime at all" + assert [t["session_key"] for t in harness_turns] == ["C_TIMEBOX:tb_root", "C_TIMEBOX:tb_root"] + + +def test_runtime_registers_no_timeboxing_agent(): + """Green once Task 5 lands; until then it names what is being retired.""" + assert not (_registered_agent_types() & RETIRING) diff --git a/tests/unit/timeboxing/test_adaptive_turn_marks_timeboxing_active.py b/tests/unit/timeboxing/test_adaptive_turn_marks_timeboxing_active.py index 4af41da3..cdde8467 100644 --- a/tests/unit/timeboxing/test_adaptive_turn_marks_timeboxing_active.py +++ b/tests/unit/timeboxing/test_adaptive_turn_marks_timeboxing_active.py @@ -6,11 +6,10 @@ if timeboxing_activity.is_active(reminder.user_id): ... "timeboxing active for %s; skipping" -But every `mark_active` call in the tree is in `agents/timeboxing/agent.py` -- -the legacy `TimeboxingFlowAgent`, which Slack only reaches when -`FF_TIMEBOX_BACKEND=legacy`, and nothing sets that. The adaptive kernel that -actually serves every turn never marked anyone active, so `is_active` was -permanently False and the guard could not fire. +But every `mark_active` call in the tree was in the legacy timeboxing agent, +which Slack had stopped routing to. The adaptive kernel that actually serves +every turn never marked anyone active, so `is_active` was permanently False +and the guard could not fire. Measured on 2026-08-31: 12 user messages produced 12 reconciles that nudged and 12 identical "No planning session on the calendar yet" cards, several four diff --git a/tests/unit/timeboxing/test_agent_journal_wiring.py b/tests/unit/timeboxing/test_agent_journal_wiring.py deleted file mode 100644 index c15d8ffd..00000000 --- a/tests/unit/timeboxing/test_agent_journal_wiring.py +++ /dev/null @@ -1,46 +0,0 @@ -# tests/unit/test_agent_journal_wiring.py -"""The agent's patcher and submitter must be journal-wrapped.""" -from __future__ import annotations - -import inspect - -from fateforger.agents.timeboxing import agent as agent_module - - -def test_agent_module_imports_journaling_decorators() -> None: - src = inspect.getsource(agent_module) - assert "JournalingPatcher" in src - assert "JournalingSubmitter" in src - - -def test_journal_is_optional_and_failure_is_tolerated() -> None: - """A journal that cannot be opened must not stop the agent from starting.""" - src = inspect.getsource(agent_module._build_journal_store) - assert "except Exception" in src - assert "return None" in src - - -def test_journal_store_is_built_without_touching_the_event_loop() -> None: - """The constructor runs inside a live loop; blocking calls would raise there. - - Because the failure path degrades to None, a blocking call would leave the - journal silently disabled in production while this suite stayed green. - """ - src = inspect.getsource(agent_module._build_journal_store) - assert "run_until_complete" not in src - assert "asyncio.run" not in src - assert "journal_sessionmaker" in src - - -async def test_build_journal_store_works_inside_a_running_loop() -> None: - """Exercise the real constraint rather than asserting on source text.""" - agent_module._JOURNAL_STORE = None - try: - assert agent_module._build_journal_store() is not None - finally: - agent_module._JOURNAL_STORE = None - - -def test_wrappers_are_skipped_when_journal_unavailable() -> None: - assert agent_module._maybe_journal_patcher(sentinel := object(), None) is sentinel - assert agent_module._maybe_journal_submitter(sentinel2 := object(), None) is sentinel2 diff --git a/tests/unit/timeboxing/test_another_turn_is_capped.py b/tests/unit/timeboxing/test_another_turn_is_capped.py new file mode 100644 index 00000000..6a58f774 --- /dev/null +++ b/tests/unit/timeboxing/test_another_turn_is_capped.py @@ -0,0 +1,103 @@ +"""A planner that keeps asking for another turn is stopped, and told so. + +Legacy commit 9eb333e capped consecutive no-change refine passes at three; +the harness's NeedsAnotherTurn had no cap. The streak is read from the +snapshot's handled_interactions, which already record every outcome kind. +""" + +from __future__ import annotations + +from fateforger.agents.timeboxing import adaptive_timeboxing as kernel_module +from fateforger.agents.timeboxing.adaptive_timeboxing import ( + InMemoryPlanningSessionRepository, +) +from fateforger.agents.timeboxing.session_contracts import ( + ArtifactDraft, + ArtifactKind, + NeedsAnotherTurn, + PlannerContinuation, + PlanningResult, + TurnFailed, +) +from tests.doubles.timeboxing import ( + RecordedPlanner, + RecordingProgressSink, + _advance_request, + _incident_snapshot, + _kernel, +) + +_REASON = "lunch still collides with the daily; shortening it next pass" +LIMIT = kernel_module.MAX_CONSECUTIVE_CONTINUATIONS + + +def _continuing_planner(): + return RecordedPlanner(PlanningResult(continuation=PlannerContinuation(reason=_REASON))) + + +async def _turns(kernel, count: int, *, first_revision: int = 3): + outcomes = [] + for i in range(count): + outcomes.append( + await kernel.turn( + _advance_request( + expected_revision=first_revision + i, + interaction_id=f"1772.{i + 2}", + ), + progress=RecordingProgressSink(), + ) + ) + return outcomes + + +def test_the_limit_allows_an_ordinary_continuation_but_not_a_loop(): + assert 2 < LIMIT < 9 + assert kernel_module.MAX_CONSECUTIVE_CONTINUATIONS == 3 + + +# 3 is the contract (legacy's _REFINE_NO_CHANGE_LIMIT), not a reading of the +# constant -- a test that loops LIMIT times cannot fail when LIMIT moves. +async def test_continuations_accumulate_then_fail_on_the_limit(): + repo = InMemoryPlanningSessionRepository([_incident_snapshot()]) + kernel = _kernel(repo, _continuing_planner()) + + outcomes = await _turns(kernel, 3) + + assert isinstance(outcomes[0], NeedsAnotherTurn) + assert isinstance(outcomes[1], NeedsAnotherTurn) + last = outcomes[2] + assert isinstance(last, TurnFailed) + assert last.code == "no_progress" + assert _REASON in last.message + + +async def test_the_failure_says_how_many_passes_made_no_progress(): + repo = InMemoryPlanningSessionRepository([_incident_snapshot()]) + (*_, last) = await _turns(_kernel(repo, _continuing_planner()), 3) + assert "3" in last.message + + +async def test_a_productive_turn_resets_the_streak(): + """Two continuations, then an artifact, then two more: no failure.""" + repo = InMemoryPlanningSessionRepository([_incident_snapshot()]) + skeleton = ArtifactDraft( + kind=ArtifactKind.SKELETON, + payload={"markdown": "## Saturday\n- 10:00 Deep work"}, + dependency_revisions={"planning_day": 1}, + ) + scripted = [ + PlanningResult(continuation=PlannerContinuation(reason=_REASON)), + PlanningResult(continuation=PlannerContinuation(reason=_REASON)), + PlanningResult(artifact_updates=[skeleton]), + PlanningResult(continuation=PlannerContinuation(reason=_REASON)), + PlanningResult(continuation=PlannerContinuation(reason=_REASON)), + ] + + class _Scripted(RecordedPlanner): + async def produce(self, brief, progress): + self.briefs.append(brief) + return scripted[len(self.briefs) - 1] + + outcomes = await _turns(_kernel(repo, _Scripted(scripted[0])), len(scripted)) + + assert not any(isinstance(o, TurnFailed) for o in outcomes), outcomes diff --git a/tests/unit/timeboxing/test_calendar_reconciliation.py b/tests/unit/timeboxing/test_calendar_reconciliation.py deleted file mode 100644 index 0a59267c..00000000 --- a/tests/unit/timeboxing/test_calendar_reconciliation.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Unit tests for calendar reconciliation and duplicate prevention.""" - -from __future__ import annotations - -from datetime import date, time - -from fateforger.agents.timeboxing.calendar_reconciliation import ( - reconcile_calendar_ops, -) -from fateforger.agents.timeboxing.tb_models import FixedWindow, TBEvent, TBPlan - -PLAN_DATE = date(2026, 2, 14) -TZ = "Europe/Amsterdam" - - -def _fw(name: str, st: time, et: time, *, event_type: str = "DW") -> TBEvent: - return TBEvent(n=name, d="", t=event_type, p=FixedWindow(st=st, et=et)) - - -def test_owned_event_fuzzy_match_becomes_update_candidate() -> None: - """Moved owned events should reconcile as update candidates, not creates.""" - remote = TBPlan(events=[_fw("Focus", time(9, 0), time(10, 0))], date=PLAN_DATE, tz=TZ) - desired = TBPlan(events=[_fw("Focus", time(9, 15), time(10, 15))], date=PLAN_DATE, tz=TZ) - - plan = reconcile_calendar_ops( - remote=remote, - desired=desired, - event_id_map={}, - remote_event_ids_by_index=["fftb-owned-1"], - ) - - assert len(plan.creates) == 0 - assert len(plan.updates) == 1 - assert plan.updates[0].match_kind == "fuzzy" - assert plan.updates[0].remote.event_id == "fftb-owned-1" - - -def test_foreign_event_changes_are_noop_and_not_created() -> None: - """Foreign events are matched but never mutated or duplicated.""" - remote = TBPlan(events=[_fw("Lunch", time(12, 0), time(13, 0), event_type="M")], date=PLAN_DATE, tz=TZ) - desired = TBPlan(events=[_fw("Lunch", time(12, 10), time(13, 10), event_type="M")], date=PLAN_DATE, tz=TZ) - - plan = reconcile_calendar_ops( - remote=remote, - desired=desired, - event_id_map={}, - remote_event_ids_by_index=["foreign-id-1"], - ) - - assert len(plan.creates) == 0 - assert len(plan.updates) == 0 - assert len(plan.noops) == 1 - assert plan.noops[0].remote.event_id == "foreign-id-1" - - -def test_foreign_overlap_with_different_summary_is_noop_and_not_created() -> None: - """Near-identical foreign overlap should not produce a duplicate desired create.""" - remote = TBPlan(events=[_fw("Lunch", time(13, 0), time(14, 0), event_type="M")], date=PLAN_DATE, tz=TZ) - desired = TBPlan(events=[_fw("Lunch Break", time(13, 0), time(14, 0), event_type="M")], date=PLAN_DATE, tz=TZ) - - plan = reconcile_calendar_ops( - remote=remote, - desired=desired, - event_id_map={}, - remote_event_ids_by_index=["foreign-id-2"], - ) - - assert len(plan.creates) == 0 - assert len(plan.updates) == 0 - assert len(plan.noops) == 1 - assert plan.noops[0].remote.event_id == "foreign-id-2" - - -def test_unmatched_owned_remote_is_delete_candidate() -> None: - """Owned remote events missing from desired should be deleted.""" - remote = TBPlan(events=[_fw("Old", time(8, 0), time(9, 0))], date=PLAN_DATE, tz=TZ) - desired = TBPlan(events=[], date=PLAN_DATE, tz=TZ) - - plan = reconcile_calendar_ops( - remote=remote, - desired=desired, - event_id_map={}, - remote_event_ids_by_index=["fftb-old-1"], - ) - - assert len(plan.deletes) == 1 - assert plan.deletes[0].event_id == "fftb-old-1" - - -def test_repeated_summaries_match_deterministically() -> None: - """Time-adjacent duplicates should reconcile one-to-one in stable order.""" - remote = TBPlan( - events=[ - _fw("Focus", time(9, 0), time(10, 0)), - _fw("Focus", time(11, 0), time(12, 0)), - ], - date=PLAN_DATE, - tz=TZ, - ) - desired = TBPlan( - events=[ - _fw("Focus", time(9, 10), time(10, 10)), - _fw("Focus", time(11, 10), time(12, 10)), - ], - date=PLAN_DATE, - tz=TZ, - ) - - plan = reconcile_calendar_ops( - remote=remote, - desired=desired, - event_id_map={}, - remote_event_ids_by_index=["fftb-a", "fftb-b"], - ) - - assert len(plan.updates) == 2 - assert [match.remote.event_id for match in plan.updates] == ["fftb-a", "fftb-b"] - assert len(plan.creates) == 0 - - -def test_remote_overlapping_snapshot_does_not_crash_reconciliation() -> None: - """Overlapping remote snapshots should reconcile instead of raising ValueError.""" - remote = TBPlan( - events=[ - _fw("Lunch", time(13, 0), time(14, 0), event_type="R"), - _fw("Deep Work: Facet extraction", time(13, 30), time(15, 0)), - ], - date=PLAN_DATE, - tz=TZ, - ) - desired = TBPlan( - events=[ - _fw("Lunch", time(13, 0), time(13, 30), event_type="R"), - _fw("Deep Work: Facet extraction", time(13, 30), time(15, 0)), - ], - date=PLAN_DATE, - tz=TZ, - ) - - plan = reconcile_calendar_ops( - remote=remote, - desired=desired, - event_id_map={"Deep Work: Facet extraction|13:30:00": "fftb-deep-1"}, - remote_event_ids_by_index=["foreign-lunch-1", "fftb-deep-1"], - ) - - assert len(plan.creates) == 0 - assert any(match.remote.event_id == "fftb-deep-1" for match in plan.updates + plan.noops) diff --git a/tests/unit/timeboxing/test_calendar_submitter.py b/tests/unit/timeboxing/test_calendar_submitter.py deleted file mode 100644 index 0a20b048..00000000 --- a/tests/unit/timeboxing/test_calendar_submitter.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Unit tests for CalendarSubmitter sync execution settings.""" - -from __future__ import annotations - -from datetime import date, time, timedelta - -import pytest - -import fateforger.agents.timeboxing.submitter as submitter_module -from fateforger.agents.timeboxing.sync_engine import SyncOp, SyncOpType, SyncTransaction -from fateforger.agents.timeboxing.tb_models import ET, FixedStart, TBEvent, TBPlan - - -def _plan() -> TBPlan: - return TBPlan( - events=[ - TBEvent( - n="Deep work", - d="", - t=ET.DW, - p=FixedStart(st=time(9, 0), dur=timedelta(hours=1)), - ) - ], - date=date(2025, 6, 15), - tz="Europe/Amsterdam", - ) - - -@pytest.mark.asyncio -async def test_submit_plan_halts_on_first_sync_error(monkeypatch: pytest.MonkeyPatch) -> None: - """Submitter should execute sync with halt_on_error enabled.""" - captured: dict[str, bool] = {} - - def _fake_plan_sync(*args, **kwargs): - _ = (args, kwargs) - return [ - SyncOp( - op_type=SyncOpType.UPDATE, - gcal_event_id="fftb1", - after_payload={"calendarId": "primary", "eventId": "fftb1"}, - ) - ] - - async def _fake_execute_sync( - ops, workbench, *, halt_on_error: bool = False - ) -> SyncTransaction: - _ = (ops, workbench) - captured["halt_on_error"] = halt_on_error - return SyncTransaction(status="committed") - - monkeypatch.setattr(submitter_module, "plan_sync", _fake_plan_sync) - monkeypatch.setattr(submitter_module, "execute_sync", _fake_execute_sync) - submitter = submitter_module.CalendarSubmitter(server_url="http://localhost:3000") - monkeypatch.setattr(submitter, "_get_workbench", lambda: object()) - - tx = await submitter.submit_plan( - desired=_plan(), - remote=_plan(), - event_id_map={}, - ) - - assert tx.status == "committed" - assert captured["halt_on_error"] is True diff --git a/tests/unit/timeboxing/test_harness_approval_action.py b/tests/unit/timeboxing/test_harness_approval_action.py index 534b92cb..373790f8 100644 --- a/tests/unit/timeboxing/test_harness_approval_action.py +++ b/tests/unit/timeboxing/test_harness_approval_action.py @@ -540,7 +540,6 @@ async def test_top_level_mention_routes_card_and_approval_through_actual_root( focus = FocusManager(ttl_seconds=60, allowed_agents=["timeboxing_agent"]) commits: list[tuple[dict, dict, str | None]] = [] - monkeypatch.setenv("FF_TIMEBOX_BACKEND", "harness") monkeypatch.setattr( settings, "slack_timeboxing_channel_id", "C1", raising=False ) diff --git a/tests/unit/timeboxing/test_mcp_workbench_shutdown.py b/tests/unit/timeboxing/test_mcp_workbench_shutdown.py index bd8fde6e..250f55d2 100644 --- a/tests/unit/timeboxing/test_mcp_workbench_shutdown.py +++ b/tests/unit/timeboxing/test_mcp_workbench_shutdown.py @@ -1,8 +1,5 @@ from __future__ import annotations -from fateforger.agents.timeboxing.mcp_clients import ( - McpCalendarClient as TimeboxingCalendarClient, -) from fateforger.haunt.reconcile import McpCalendarClient as HauntCalendarClient @@ -18,14 +15,6 @@ def close(self) -> None: self.close_calls += 1 -class _CloseOnlyWorkbench: - def __init__(self) -> None: - self.close_calls = 0 - - async def close(self) -> None: - self.close_calls += 1 - - async def test_haunt_calendar_client_prefers_stop() -> None: client = object.__new__(HauntCalendarClient) wb = _StopWorkbench() @@ -35,16 +24,3 @@ async def test_haunt_calendar_client_prefers_stop() -> None: assert wb.stop_calls == 1 assert wb.close_calls == 0 - - -async def test_timeboxing_calendar_client_requires_stop() -> None: - client = object.__new__(TimeboxingCalendarClient) - wb = _CloseOnlyWorkbench() - client._workbench = wb - - try: - await client.close() - except AttributeError as exc: - assert "stop" in str(exc) - else: # pragma: no cover - strict shutdown path must call stop() - raise AssertionError("Expected AttributeError when stop() is missing") diff --git a/tests/unit/timeboxing/test_memory_surface.py b/tests/unit/timeboxing/test_memory_surface.py deleted file mode 100644 index 6e704f51..00000000 --- a/tests/unit/timeboxing/test_memory_surface.py +++ /dev/null @@ -1,193 +0,0 @@ -"""The memory surface inside a timeboxing session: the review actions a card -offers, and how a memory tool's result is turned into something to show. -""" - -from __future__ import annotations - -from types import MethodType -import pytest -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.preferences import ConstraintBase, ConstraintNecessity -from fateforger.agents.timeboxing.tool_result_models import MemoryToolResult -from fateforger.agents.timeboxing.tool_result_models import ( - MemoryConstraintItem, - MemoryToolResult, -) - - -# ── review actions ──────────────────────────────────────────────────────────── - -class _FakeStore: - async def query_constraints(self, **kwargs): # noqa: ANN003 - _ = kwargs - return [ - { - "uid": "uid-active", - "constraint_record": { - "name": "Protect mornings", - "description": "No meetings before 11:00", - "status": "locked", - "scope": "profile", - "source": "user", - }, - }, - { - "uid": "uid-idle", - "constraint_record": { - "name": "Gym evening", - "description": "Workout after work", - "status": "proposed", - "scope": "session", - "source": "system", - }, - }, - ] - - -@pytest.mark.asyncio -async def test_memory_list_marks_used_this_session() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.active_constraints = [] - session.suppressed_durable_uids = set() - store = _FakeStore() - memory_ops: list[str] = [] - captured: dict[str, MemoryToolResult] = {} - - async def _collect_constraints(_self, _session): # noqa: ANN001 - return [ - ConstraintBase( - name="Protect mornings", - description="No meetings before 11:00", - necessity=ConstraintNecessity.SHOULD, - hints={"uid": "uid-active"}, - ) - ] - - def _record(_self, *, session, result): # noqa: ANN001 - _ = session - captured["result"] = result - return result.to_tool_payload() - - agent._ensure_durable_constraint_store = lambda: store - agent._append_background_update_once = lambda *_args, **_kwargs: None - agent._collect_constraints = MethodType(_collect_constraints, agent) - agent._record_memory_tool_result = MethodType(_record, agent) - - payload = await TimeboxingFlowAgent._run_memory_tool_action( - agent, - action="list", - session=session, - memory_operations=memory_ops, - memory_request_text="which memories are active?", - text_query=None, - statuses=None, - scopes=None, - necessities=None, - tags=None, - limit=20, - ) - - assert payload["ok"] is True - assert memory_ops == ["list:2"] - by_uid = {item["uid"]: item for item in payload["constraints"]} - assert by_uid["uid-active"]["used_this_session"] is True - assert by_uid["uid-idle"]["used_this_session"] is False - assert captured["result"].count == 2 - - -@pytest.mark.asyncio -async def test_memory_action_guarded_returns_structured_error_on_backend_failure() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.active_constraints = [] - session.suppressed_durable_uids = set() - memory_ops: list[str] = [] - updates: list[str] = [] - - async def _boom(_self, **kwargs): # noqa: ANN001, ANN003 - _ = kwargs - raise RuntimeError("constraint-memory tool constraint_query_constraints failed") - - def _record(_self, *, session, result): # noqa: ANN001 - _ = session - return result.to_tool_payload() - - agent._run_memory_tool_action = MethodType(_boom, agent) - agent._append_background_update_once = lambda _session, text: updates.append(text) - agent._session_debug = lambda *_args, **_kwargs: None - agent._record_memory_tool_result = MethodType(_record, agent) - agent._durable_constraint_store = object() - agent._constraint_memory_unavailable_reason = None - - payload = await TimeboxingFlowAgent._run_memory_tool_action_guarded( - agent, - action="list", - session=session, - memory_operations=memory_ops, - memory_request_text="which memories are active?", - text_query=None, - statuses=None, - scopes=None, - necessities=None, - tags=None, - limit=20, - ) - - assert payload["ok"] is False - assert payload["action"] == "list" - assert "unavailable" in payload.get("message", "").lower() - assert agent._durable_constraint_store is None - assert "RuntimeError" in (agent._constraint_memory_unavailable_reason or "") - assert any("memory backend is currently unavailable" in text.lower() for text in updates) - - -# ── tool results ────────────────────────────────────────────────────────────── - -def test_memory_constraint_item_from_nested_constraint_record() -> None: - item = MemoryConstraintItem.from_payload( - { - "uid": "tb_1", - "constraint_record": { - "name": "Deep work mornings", - "description": "Protect 09:00-12:00 for deep work", - "necessity": "should", - "status": "proposed", - "scope": "profile", - "source": "system", - "confidence": 0.52, - "selector": {"needs_confirmation": True}, - }, - } - ) - assert item is not None - assert item.uid == "tb_1" - assert item.needs_confirmation is True - assert item.status == "proposed" - assert item.scope == "profile" - assert item.source == "system" - assert item.used_this_session is False - - -def test_memory_tool_result_serializes_constraints() -> None: - result = MemoryToolResult( - action="get", - ok=True, - uid="tb_2", - constraints=[ - MemoryConstraintItem( - uid="tb_2", - name="No late calls", - description="Avoid calls after 18:00", - status="locked", - scope="profile", - source="user", - used_this_session=True, - ) - ], - ) - payload = result.to_tool_payload() - assert payload["action"] == "get" - assert payload["ok"] is True - assert payload["constraints"][0]["uid"] == "tb_2" - assert payload["constraints"][0]["used_this_session"] is True diff --git a/tests/unit/timeboxing/test_patching.py b/tests/unit/timeboxing/test_patching.py deleted file mode 100644 index df239f03..00000000 --- a/tests/unit/timeboxing/test_patching.py +++ /dev/null @@ -1,341 +0,0 @@ -"""Unit tests for fateforger.agents.timeboxing.patching. - -Tests ``_extract_patch()`` (including fenced-JSON handling), -``_build_context()``, and ``_patcher_system_prompt_with_schema()``. -""" - -from __future__ import annotations - -import json -from datetime import date, time, timedelta -from types import SimpleNamespace - -import pytest - -from fateforger.agents.timeboxing import patching as patching_module -from fateforger.agents.timeboxing.planning_policy import ( - PLANNING_POLICY_VERSION, - SHARED_PLANNING_POLICY_PROMPT, - STAGE4_REFINEMENT_PROMPT, -) -from fateforger.agents.timeboxing.patching import ( - _PATCHER_SYSTEM_PROMPT, - _build_context, - _extract_patch, - _patcher_system_prompt_with_schema, - TimeboxPatcher, -) -from fateforger.agents.timeboxing.tb_models import ( - ET, - AfterPrev, - FixedStart, - TBEvent, - TBPlan, -) -from fateforger.agents.timeboxing.tb_ops import AddEvents, TBPatch, UpdateEvent - -# ── Fixtures ────────────────────────────────────────────────────────────── - - -@pytest.fixture() -def simple_plan() -> TBPlan: - """A minimal TBPlan for testing.""" - return TBPlan( - date=date(2026, 2, 15), - tz="Europe/Amsterdam", - events=[ - TBEvent( - n="Morning routine", - t=ET.H, - p=FixedStart(st=time(7, 0), dur=timedelta(minutes=30)), - ), - TBEvent( - n="Deep work", - t=ET.DW, - p=AfterPrev(dur=timedelta(hours=2)), - ), - ], - ) - - -def _make_response(content: object) -> SimpleNamespace: - """Build a fake AutoGen Response with the given content.""" - return SimpleNamespace(chat_message=SimpleNamespace(content=content)) - - -# ── _extract_patch ──────────────────────────────────────────────────────── - - -class TestExtractPatch: - """Tests for ``_extract_patch()``.""" - - def test_structured_tbpatch_object(self) -> None: - """If content is already a TBPatch, return it directly.""" - patch = TBPatch(ops=[UpdateEvent(i=0, n="Renamed")]) - resp = _make_response(patch) - assert _extract_patch(resp) is patch - - def test_raw_json_string(self) -> None: - """Parse a clean JSON string into TBPatch.""" - raw = json.dumps({"ops": [{"op": "ue", "i": 0, "n": "Renamed"}]}) - resp = _make_response(raw) - patch = _extract_patch(resp) - assert len(patch.ops) == 1 - assert patch.ops[0].op == "ue" - - def test_fenced_json_string(self) -> None: - """Parse JSON wrapped in markdown code fences.""" - raw = '```json\n{"ops": [{"op": "ue", "i": 1, "n": "Updated"}]}\n```' - resp = _make_response(raw) - patch = _extract_patch(resp) - assert len(patch.ops) == 1 - assert patch.ops[0].n == "Updated" - - def test_fenced_json_no_language_tag(self) -> None: - """Parse JSON wrapped in bare ``` fences (no language tag).""" - raw = '```\n{"ops": [{"op": "re", "i": 0}]}\n```' - resp = _make_response(raw) - patch = _extract_patch(resp) - assert patch.ops[0].op == "re" - - def test_fenced_json_multiline(self) -> None: - """Parse a multi-line fenced JSON block.""" - raw = ( - "```json\n" - "{\n" - ' "ops": [\n' - ' {"op": "ae", "events": [{"n": "Lunch", "t": "R", ' - '"p": {"a": "fs", "st": "12:00", "dur": "PT30M"}}]}\n' - " ]\n" - "}\n" - "```" - ) - resp = _make_response(raw) - patch = _extract_patch(resp) - assert len(patch.ops) == 1 - assert patch.ops[0].op == "ae" - - def test_dict_content(self) -> None: - """Parse a dict directly into TBPatch.""" - d = {"ops": [{"op": "ue", "i": 0, "n": "Renamed"}]} - resp = _make_response(d) - patch = _extract_patch(resp) - assert patch.ops[0].n == "Renamed" - - def test_invalid_content_raises(self) -> None: - """Raise ValueError for unparseable content.""" - resp = _make_response(42) - with pytest.raises(ValueError, match="Could not extract TBPatch"): - _extract_patch(resp) - - def test_invalid_json_string_raises(self) -> None: - """Raise ValueError for a string that is not valid JSON.""" - resp = _make_response("this is not json") - with pytest.raises(ValueError, match="TBPatch parse/validation failed"): - _extract_patch(resp) - - def test_invalid_dict_raises_validation_details(self) -> None: - """Raise ValueError with details for malformed dict payloads.""" - resp = _make_response({"ops": [{"op": "ue", "i": "bad-index"}]}) - with pytest.raises(ValueError, match="TBPatch validation failed from dict payload"): - _extract_patch(resp) - - -# ── _build_context ──────────────────────────────────────────────────────── - - -class TestBuildContext: - """Tests for ``_build_context()``.""" - - def test_contains_plan_json(self, simple_plan: TBPlan) -> None: - """Context includes the plan as JSON.""" - ctx = _build_context(simple_plan, "add lunch", [], []) - assert '"Morning routine"' in ctx - assert '"Deep work"' in ctx - - def test_contains_user_message(self, simple_plan: TBPlan) -> None: - """Context includes the user's instruction.""" - ctx = _build_context(simple_plan, "add a lunch break at noon", [], []) - assert "add a lunch break at noon" in ctx - - def test_contains_produce_directive(self, simple_plan: TBPlan) -> None: - """Context ends with the produce directive.""" - ctx = _build_context(simple_plan, "anything", [], []) - assert "Produce the TBPatch JSON" in ctx - - -# ── _patcher_system_prompt_with_schema ──────────────────────────────────── - - -class TestPatcherSystemPrompt: - """Tests for ``_patcher_system_prompt_with_schema()``.""" - - def test_includes_base_prompt(self) -> None: - """The augmented prompt includes the original system prompt.""" - full = _patcher_system_prompt_with_schema() - assert "timebox refinement assistant" in full - - def test_includes_json_schema(self) -> None: - """The augmented prompt includes the TBPatch JSON schema.""" - full = _patcher_system_prompt_with_schema() - assert "TBPatch JSON Schema" in full - assert '"title": "TBPatch"' in full - - def test_includes_no_fences_instruction(self) -> None: - """The augmented prompt tells the LLM not to use fences.""" - full = _patcher_system_prompt_with_schema() - assert "no markdown fences" in full - - def test_includes_shared_planning_policy(self) -> None: - """Patcher prompt should include the shared policy version + content.""" - full = _patcher_system_prompt_with_schema() - assert PLANNING_POLICY_VERSION in full - assert SHARED_PLANNING_POLICY_PROMPT.splitlines()[0] in full - assert STAGE4_REFINEMENT_PROMPT.splitlines()[0] in full - - -@pytest.mark.asyncio -async def test_apply_patch_rejects_non_refine_stage( - simple_plan: TBPlan, -) -> None: - """Patcher API should hard-fail if called for a non-Refine stage.""" - patcher = TimeboxPatcher(model_client=object(), max_attempts=1) - with pytest.raises(ValueError, match="only supports stage='Refine'"): - await patcher.apply_patch( # type: ignore[arg-type] - stage="Skeleton", - current=simple_plan, - user_message="anything", - constraints=[], - actions=[], - ) - - -@pytest.mark.asyncio -async def test_apply_patch_retries_on_validator_failure( - simple_plan: TBPlan, monkeypatch: pytest.MonkeyPatch -) -> None: - """Retry should include validator errors in the second patch attempt context.""" - contexts: list[str] = [] - attempts = {"validator": 0} - raw_patch = json.dumps({"ops": [{"op": "ue", "i": 1, "n": "Deep work (updated)"}]}) - - class _FakeAssistant: - def __init__(self, **kwargs: object) -> None: - _ = kwargs - - async def on_messages(self, messages: list[object], cancellation_token: object) -> object: - _ = cancellation_token - contexts.append(getattr(messages[0], "content", "")) - return _make_response(raw_patch) - - async def _passthrough_timeout( - label: str, awaitable: object, *, timeout_s: float - ) -> object: - _ = (label, timeout_s) - return await awaitable # type: ignore[misc] - - def _validator(_plan: TBPlan) -> None: - attempts["validator"] += 1 - if attempts["validator"] == 1: - raise ValueError("Overlap detected between events.") - - monkeypatch.setattr(patching_module, "AssistantAgent", _FakeAssistant) - monkeypatch.setattr(patching_module, "with_timeout", _passthrough_timeout) - patcher = TimeboxPatcher(model_client=object(), max_attempts=2) - - patched, patch = await patcher.apply_patch( - stage="Refine", - current=simple_plan, - user_message="adjust deep work", - constraints=[], - actions=[], - plan_validator=_validator, - ) - - assert patch.ops[0].op == "ue" - assert patched.events[1].n == "Deep work (updated)" - assert attempts["validator"] == 2 - assert len(contexts) == 2 - assert "Previous patch attempt failed." in contexts[1] - assert "Overlap detected between events." in contexts[1] - - -@pytest.mark.asyncio -async def test_apply_patch_raises_after_max_attempts( - simple_plan: TBPlan, monkeypatch: pytest.MonkeyPatch -) -> None: - """Patcher should raise a bounded error after exhausting retries.""" - raw_patch = json.dumps({"ops": [{"op": "ue", "i": 1, "n": "Deep work (updated)"}]}) - - class _FakeAssistant: - def __init__(self, **kwargs: object) -> None: - _ = kwargs - - async def on_messages(self, messages: list[object], cancellation_token: object) -> object: - _ = (messages, cancellation_token) - return _make_response(raw_patch) - - async def _passthrough_timeout( - label: str, awaitable: object, *, timeout_s: float - ) -> object: - _ = (label, timeout_s) - return await awaitable # type: ignore[misc] - - def _validator(_plan: TBPlan) -> None: - raise ValueError("Still invalid: overlap remains.") - - monkeypatch.setattr(patching_module, "AssistantAgent", _FakeAssistant) - monkeypatch.setattr(patching_module, "with_timeout", _passthrough_timeout) - patcher = TimeboxPatcher(model_client=object(), max_attempts=2) - - with pytest.raises(ValueError, match="failed after 2 attempts"): - await patcher.apply_patch( - stage="Refine", - current=simple_plan, - user_message="adjust deep work", - constraints=[], - actions=[], - plan_validator=_validator, - ) - - -@pytest.mark.asyncio -async def test_apply_patch_stops_on_non_retryable_provider_error( - simple_plan: TBPlan, monkeypatch: pytest.MonkeyPatch -) -> None: - """Non-retryable provider errors should fail fast instead of spending all retries.""" - attempts = {"calls": 0} - - class _PermissionDeniedError(Exception): - status_code = 403 - - class _FakeAssistant: - def __init__(self, **kwargs: object) -> None: - _ = kwargs - - async def on_messages( - self, messages: list[object], cancellation_token: object - ) -> object: - _ = (messages, cancellation_token) - attempts["calls"] += 1 - raise _PermissionDeniedError("forbidden") - - async def _passthrough_timeout( - label: str, awaitable: object, *, timeout_s: float - ) -> object: - _ = (label, timeout_s) - return await awaitable # type: ignore[misc] - - monkeypatch.setattr(patching_module, "AssistantAgent", _FakeAssistant) - monkeypatch.setattr(patching_module, "with_timeout", _passthrough_timeout) - patcher = TimeboxPatcher(model_client=object(), max_attempts=5) - - with pytest.raises(ValueError, match="non-retryable error"): - await patcher.apply_patch( - stage="Refine", - current=simple_plan, - user_message="adjust deep work", - constraints=[], - actions=[], - ) - assert attempts["calls"] == 1 diff --git a/tests/unit/timeboxing/test_phase4_rewiring.py b/tests/unit/timeboxing/test_phase4_rewiring.py deleted file mode 100644 index f861d770..00000000 --- a/tests/unit/timeboxing/test_phase4_rewiring.py +++ /dev/null @@ -1,943 +0,0 @@ -"""Phase 4: tests for Session rewiring and node integration. - -Verifies that: -- Session has new fields (tb_plan, base_snapshot, event_id_map) -- StageSkeletonNode keeps Stage 3 markdown-focused and defers baseline to Stage 4 -- StageRefineNode patches via TBPlan and keeps Timebox in sync -- StageReviewCommitNode calls CalendarSubmitter when tb_plan is present -- _update_timebox_with_feedback uses TBPlan when available -""" - -from __future__ import annotations - -import asyncio -import types -from datetime import date, time, timedelta -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -pytest.importorskip("autogen_agentchat") - -from fateforger.agents.schedular.models.calendar import CalendarEvent, EventType -from fateforger.agents.timeboxing.agent import ( - CalendarSyncOutcome, - RefinePreflight, - RefineQualityFacts, - RefineToolExecutionOutcome, - Session, - TimeboxingFlowAgent, -) -from fateforger.agents.timeboxing.stage_gating import StageGateOutput, TimeboxingStage -from fateforger.agents.timeboxing.tb_models import ( - ET, - AfterPrev, - FixedStart, - FixedWindow, - TBEvent, - TBPlan, -) -from fateforger.agents.timeboxing.timebox import Timebox, timebox_to_tb_plan - -# ── Session field tests ────────────────────────────────────────────────── - - -class TestSessionNewFields: - """Verify new fields on Session dataclass.""" - - def test_session_has_tb_plan_field(self) -> None: - """Session should have an optional tb_plan field, defaulting to None.""" - s = Session(thread_ts="t1", channel_id="c1", user_id="u1") - assert s.tb_plan is None - - def test_session_has_base_snapshot_field(self) -> None: - """Session should have an optional base_snapshot field, defaulting to None.""" - s = Session(thread_ts="t1", channel_id="c1", user_id="u1") - assert s.base_snapshot is None - - def test_session_has_event_id_map_field(self) -> None: - """Session should have an event_id_map dict, defaulting to empty.""" - s = Session(thread_ts="t1", channel_id="c1", user_id="u1") - assert s.event_id_map == {} - assert isinstance(s.event_id_map, dict) - - def test_session_tb_plan_can_be_set(self) -> None: - """Session.tb_plan can be assigned a TBPlan.""" - s = Session(thread_ts="t1", channel_id="c1", user_id="u1") - plan = TBPlan( - events=[ - TBEvent( - n="Test", - t=ET.DW, - p=FixedStart(st=time(9, 0), dur=timedelta(hours=1)), - ), - ], - date=date(2026, 2, 13), - ) - s.tb_plan = plan - assert s.tb_plan is plan - assert len(s.tb_plan.events) == 1 - - def test_session_event_id_map_independent_per_session(self) -> None: - """Each session should get its own event_id_map dict.""" - s1 = Session(thread_ts="t1", channel_id="c1", user_id="u1") - s2 = Session(thread_ts="t2", channel_id="c1", user_id="u1") - s1.event_id_map["key1"] = "val1" - assert "key1" not in s2.event_id_map - - -# ── Timebox β†’ TBPlan round-trip ────────────────────────────────────────── - - -class TestTimeboxTBPlanRoundTrip: - """Verify that the round-trip conversion preserves semantics.""" - - def test_timebox_to_tb_plan_preserves_events(self) -> None: - """Converting a Timebox to TBPlan should preserve event count and names.""" - timebox = Timebox( - events=[ - CalendarEvent( - summary="Morning routine", - event_type=EventType.HABIT, - start_time=time(7, 0), - duration=timedelta(minutes=30), - ), - CalendarEvent( - summary="Deep work", - event_type=EventType.DEEP_WORK, - start_time=time(9, 0), - duration=timedelta(hours=2), - ), - ], - date=date(2026, 2, 13), - timezone="Europe/Amsterdam", - ) - plan = timebox_to_tb_plan(timebox) - assert len(plan.events) == 2 - assert plan.events[0].n == "Morning routine" - assert plan.events[1].n == "Deep work" - assert plan.date == date(2026, 2, 13) - assert plan.tz == "Europe/Amsterdam" - - def test_tb_plan_resolves_after_round_trip(self) -> None: - """TBPlan from round-trip conversion should still resolve times.""" - timebox = Timebox( - events=[ - CalendarEvent( - summary="Focus block", - event_type=EventType.DEEP_WORK, - start_time=time(9, 0), - end_time=time(11, 0), - ), - ], - date=date(2026, 2, 13), - timezone="Europe/Amsterdam", - ) - plan = timebox_to_tb_plan(timebox) - resolved = plan.resolve_times() - assert len(resolved) == 1 - assert resolved[0]["start_time"] == time(9, 0) - assert resolved[0]["end_time"] == time(11, 0) - - -# ── CalendarSubmitter integration ──────────────────────────────────────── - - -class TestCalendarSubmitterOnAgent: - """Verify CalendarSubmitter is instantiated on TimeboxingFlowAgent.""" - - def test_agent_has_calendar_submitter(self) -> None: - """TimeboxingFlowAgent should have a _calendar_submitter attribute.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - # Minimal init to set up the submitter - from fateforger.agents.timeboxing.submitter import CalendarSubmitter - - agent._calendar_submitter = CalendarSubmitter() - assert hasattr(agent, "_calendar_submitter") - - -# ── StageSkeletonNode keeps Stage 3 markdown-only ──────────────────────── - - -class TestSkeletonNodeMarkdownOnly: - """Verify StageSkeletonNode defers Stage 4 plan/snapshot preparation.""" - - @pytest.mark.asyncio - async def test_skeleton_draft_defers_snapshot_to_stage4(self, monkeypatch) -> None: - """After skeleton draft, Stage 3 should not build a baseline snapshot.""" - from autogen_ext.models.openai import OpenAIChatCompletionClient - - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._draft_model_client = OpenAIChatCompletionClient( - model="gpt-4o-mini", api_key="test" - ) - agent._constraint_store = None - - # Mock _run_skeleton_draft to return our timebox + markdown overview - async def mock_skeleton_draft(session: Session) -> tuple[None, str, TBPlan]: - _ = session - drafted_plan = TBPlan( - date=date(2026, 2, 13), - tz="Europe/Amsterdam", - events=[ - TBEvent( - n="Focus Block", - t=ET.DW, - p=FixedWindow(st=time(9, 0), et=time(10, 30)), - ) - ], - ) - return None, "## Day Overview\n- Focus Block", drafted_plan - - agent._run_skeleton_draft = types.MethodType( - lambda self, s: mock_skeleton_draft(s), agent - ) - - agent._build_remote_snapshot_plan = types.MethodType( # type: ignore[attr-defined] - lambda self, _session: (_ for _ in ()).throw( - AssertionError("Stage 3 should not build remote snapshot.") - ), - agent, - ) - agent._render_markdown_summary_blocks = types.MethodType( - lambda self, text: [], - agent, - ) - - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-02-13", - tz_name="Europe/Amsterdam", - frame_facts={"work_window": {"start": "09:00", "end": "17:00"}}, - input_facts={"tasks": [{"name": "Study"}]}, - ) - - # Import and call the node - from fateforger.agents.timeboxing.nodes.nodes import ( - StageSkeletonNode, - TransitionNode, - ) - - transition = TransitionNode.__new__(TransitionNode) - transition.decision = None - transition.stage_user_message = "" - - node = StageSkeletonNode( - orchestrator=agent, session=session, transition=transition - ) - - from autogen_agentchat.messages import TextMessage - from autogen_core import CancellationToken - - await node.on_messages( - [TextMessage(content="go", source="user")], - CancellationToken(), - ) - - # Verify Stage 3 keeps markdown + draft plan, and defers snapshot to Stage 4. - assert session.timebox is None - assert session.tb_plan is not None - assert session.base_snapshot is None - assert session.skeleton_overview_markdown == "## Day Overview\n- Focus Block" - assert len(session.tb_plan.events) == 1 - assert session.tb_plan.events[0].n == "Focus Block" - - -# ── StageRefineNode uses TBPlan ────────────────────────────────────────── - - -class TestRefineNodeUsesTBPlan: - """Verify StageRefineNode patches via TBPlan when available.""" - - def test_session_with_tb_plan_uses_new_path(self) -> None: - """When session.tb_plan is set, refine should run tool orchestration (not legacy path).""" - # This is a structural test β€” verify the node code branches on tb_plan - import inspect - - from fateforger.agents.timeboxing.nodes.nodes import StageRefineNode - - source = inspect.getsource(StageRefineNode.on_messages) - # Should reference tb_plan in the patching logic - assert "tb_plan" in source - # Stage 4 should use orchestrated tool execution. - assert "apply_patch_legacy" not in source - assert "_run_refine_tool_orchestration" in source - - @pytest.mark.asyncio - async def test_refine_node_bootstraps_skeleton_when_draft_missing(self) -> None: - """Refine should seed TBPlan/Timebox from Skeleton when both are missing.""" - from autogen_agentchat.messages import TextMessage - from autogen_core import CancellationToken - - from fateforger.agents.timeboxing.nodes.nodes import StageRefineNode, TransitionNode - - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - seed_timebox = Timebox( - events=[ - CalendarEvent( - summary="Focus Block", - event_type=EventType.DEEP_WORK, - start_time=time(9, 0), - duration=timedelta(minutes=90), - ) - ], - date=date(2026, 2, 13), - timezone="Europe/Amsterdam", - ) - seed_plan = timebox_to_tb_plan(seed_timebox) - consume_calls: list[Session] = [] - - async def _consume(_self, session: Session) -> tuple[Timebox, str, TBPlan]: - consume_calls.append(session) - return seed_timebox, "## Day Overview\n- Focus Block", seed_plan - - def _ensure_refine(_self, session: Session) -> RefinePreflight: - session.tb_plan = seed_plan - session.base_snapshot = seed_plan.model_copy(deep=True) - return RefinePreflight() - - async def _run_summary( - *, stage, timebox, session=None, allow_quality_enrichment=True - ) -> StageGateOutput: - _ = (timebox, session, allow_quality_enrichment) - return StageGateOutput( - stage_id=stage, - ready=True, - summary=["Updated schedule."], - missing=[], - question="Proceed?", - facts={}, - ) - - async def _run_orchestration( - *, - session: Session, - patch_message: str, - user_message: str, - ) -> RefineToolExecutionOutcome: - _ = (patch_message, user_message) - session.tb_plan = seed_plan - session.timebox = seed_timebox - return RefineToolExecutionOutcome( - patch_selected=True, - memory_selected=False, - memory_queued=False, - fallback_patch_used=False, - calendar=CalendarSyncOutcome( - status="committed", - changed=False, - note="Calendar unchanged: no sync operations were needed.", - ), - ) - - agent._consume_pre_generated_skeleton = types.MethodType( # type: ignore[attr-defined] - _consume, - agent, - ) - agent._ensure_refine_plan_state = types.MethodType( # type: ignore[attr-defined] - _ensure_refine, - agent, - ) - agent._run_refine_tool_orchestration = types.MethodType( # type: ignore[attr-defined] - lambda self, **kwargs: _run_orchestration(**kwargs), - agent, - ) - agent._run_timebox_summary = types.MethodType( # type: ignore[attr-defined] - lambda self, **kwargs: _run_summary(**kwargs), - agent, - ) - agent._ensure_calendar_immovables = types.MethodType( # type: ignore[attr-defined] - lambda self, session: asyncio.sleep(0), - agent, - ) - agent._compose_patcher_message = types.MethodType( # type: ignore[attr-defined] - lambda self, **kwargs: kwargs["base_message"], - agent, - ) - agent._quality_snapshot_for_prompt = types.MethodType( # type: ignore[attr-defined] - lambda self, session: {}, - agent, - ) - agent._materialize_timebox_from_tb_plan = types.MethodType( # type: ignore[attr-defined] - lambda self, session: seed_timebox, - agent, - ) - - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - stage=TimeboxingStage.REFINE, - ) - transition = TransitionNode.__new__(TransitionNode) - transition.stage_user_message = "swap deep and shallow blocks" - transition.decision = None - - node = StageRefineNode( - orchestrator=agent, - session=session, - transition=transition, - ) - - await node.on_messages( - [TextMessage(content="swap deep and shallow blocks", source="user")], - CancellationToken(), - ) - - assert len(consume_calls) == 1 - assert session.timebox is not None - assert session.tb_plan is not None - assert node.last_gate is not None - assert node.last_gate.ready is True - - @pytest.mark.asyncio - async def test_refine_node_runs_repair_patch_when_preflight_reports_issue(self) -> None: - """Preflight issues should be injected into patch-loop context.""" - from autogen_agentchat.messages import TextMessage - from autogen_core import CancellationToken - - from fateforger.agents.timeboxing.nodes.nodes import StageRefineNode, TransitionNode - - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - seeded_plan = TBPlan( - date=date(2026, 2, 13), - tz="Europe/Amsterdam", - events=[ - TBEvent( - n="Wake Up", - t=ET.H, - p=FixedStart(st=time(9, 0), dur=timedelta(minutes=30)), - ) - ], - ) - - patch_messages: list[str] = [] - - def _ensure_refine(_self, session: Session) -> RefinePreflight: - session.tb_plan = seeded_plan - session.base_snapshot = seeded_plan.model_copy(deep=True) - return RefinePreflight( - plan_issues=[ - "timebox_to_tb_plan: Event chain needs at least one fixed_start or fixed_window anchor" - ] - ) - - async def _run_summary( - *, stage, timebox, session=None, allow_quality_enrichment=True - ) -> StageGateOutput: - _ = (timebox, session, allow_quality_enrichment) - return StageGateOutput( - stage_id=stage, - ready=True, - summary=["Updated schedule."], - missing=[], - question="Proceed?", - facts={}, - ) - - async def _run_orchestration( - *, - session: Session, - patch_message: str, - user_message: str, - ) -> RefineToolExecutionOutcome: - _ = user_message - patch_messages.append(patch_message) - session.tb_plan = seeded_plan - session.timebox = Timebox( - events=[ - CalendarEvent( - summary="Wake Up", - event_type=EventType.HABIT, - start_time=time(9, 0), - duration=timedelta(minutes=30), - ) - ], - date=date(2026, 2, 13), - timezone="Europe/Amsterdam", - ) - return RefineToolExecutionOutcome( - patch_selected=True, - memory_selected=False, - memory_queued=False, - fallback_patch_used=False, - calendar=CalendarSyncOutcome( - status="committed", - changed=False, - note="Calendar unchanged: no sync operations were needed.", - ), - ) - - agent._ensure_refine_plan_state = types.MethodType( # type: ignore[attr-defined] - _ensure_refine, - agent, - ) - agent._run_refine_tool_orchestration = types.MethodType( # type: ignore[attr-defined] - lambda self, **kwargs: _run_orchestration(**kwargs), - agent, - ) - agent._run_timebox_summary = types.MethodType( # type: ignore[attr-defined] - lambda self, **kwargs: _run_summary(**kwargs), - agent, - ) - - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - stage=TimeboxingStage.REFINE, - ) - session.timebox = Timebox( - events=[ - CalendarEvent( - summary="Wake Up", - event_type=EventType.HABIT, - start_time=time(9, 0), - duration=timedelta(minutes=30), - ) - ], - date=date(2026, 2, 13), - timezone="Europe/Amsterdam", - ) - - transition = TransitionNode.__new__(TransitionNode) - transition.stage_user_message = "" - transition.decision = None - - node = StageRefineNode( - orchestrator=agent, - session=session, - transition=transition, - ) - - await node.on_messages( - [TextMessage(content="proceed", source="user")], - CancellationToken(), - ) - - assert patch_messages - assert "Repair the current plan first" in patch_messages[0] - assert "Preflight validation issues:" in patch_messages[0] - - @pytest.mark.asyncio - async def test_refine_node_appends_calendar_sync_note(self) -> None: - """Refine stage should include calendar sync feedback in the summary.""" - from autogen_agentchat.messages import TextMessage - from autogen_core import CancellationToken - - from fateforger.agents.timeboxing.nodes.nodes import StageRefineNode, TransitionNode - - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - - async def _run_summary( - *, stage, timebox, session=None, allow_quality_enrichment=True - ) -> StageGateOutput: - _ = (timebox, session, allow_quality_enrichment) - return StageGateOutput( - stage_id=stage, - ready=True, - summary=["Updated schedule."], - missing=[], - question="Proceed?", - facts={}, - ) - - async def _run_orchestration( - *, - session: Session, - patch_message: str, - user_message: str, - ) -> RefineToolExecutionOutcome: - _ = (patch_message, user_message) - return RefineToolExecutionOutcome( - patch_selected=True, - memory_selected=True, - memory_queued=True, - fallback_patch_used=False, - calendar=CalendarSyncOutcome( - status="committed", - changed=True, - created=1, - updated=0, - deleted=0, - note="Calendar changed: 1 created, 0 updated, 0 deleted.", - ), - ) - - agent._run_timebox_summary = types.MethodType( # type: ignore[attr-defined] - lambda self, **kwargs: _run_summary(**kwargs), - agent, - ) - agent._run_refine_tool_orchestration = types.MethodType( # type: ignore[attr-defined] - lambda self, **kwargs: _run_orchestration(**kwargs), - agent, - ) - - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - stage=TimeboxingStage.REFINE, - ) - session.timebox = Timebox( - events=[ - CalendarEvent( - summary="Focus", - event_type=EventType.DEEP_WORK, - start_time=time(9, 0), - duration=timedelta(minutes=90), - ) - ], - date=date(2026, 2, 13), - timezone="Europe/Amsterdam", - ) - session.tb_plan = timebox_to_tb_plan(session.timebox) - session.base_snapshot = session.tb_plan.model_copy(deep=True) - - transition = TransitionNode.__new__(TransitionNode) - transition.stage_user_message = "" - transition.decision = None - - node = StageRefineNode( - orchestrator=agent, - session=session, - transition=transition, - ) - - await node.on_messages( - [TextMessage(content="proceed", source="user")], - CancellationToken(), - ) - - assert node.last_gate is not None - assert "Calendar changed: 1 created, 0 updated, 0 deleted." in node.last_gate.summary - - @pytest.mark.asyncio - async def test_refine_node_uses_budget_fastpath_when_turn_budget_low(self) -> None: - """Refine should skip LLM summary/quality when remaining turn budget is too low.""" - from autogen_agentchat.messages import TextMessage - from autogen_core import CancellationToken - - from fateforger.agents.timeboxing.nodes.nodes import StageRefineNode, TransitionNode - - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - stage=TimeboxingStage.REFINE, - ) - session.timebox = Timebox( - events=[ - CalendarEvent( - summary="Focus", - event_type=EventType.DEEP_WORK, - start_time=time(9, 0), - duration=timedelta(minutes=90), - ) - ], - date=date(2026, 2, 13), - timezone="Europe/Amsterdam", - ) - session.tb_plan = timebox_to_tb_plan(session.timebox) - session.base_snapshot = session.tb_plan.model_copy(deep=True) - - def _ensure_refine(_self, _session: Session) -> RefinePreflight: - return RefinePreflight() - - async def _run_orchestration( - *, - session: Session, - patch_message: str, - user_message: str, - ) -> RefineToolExecutionOutcome: - _ = (patch_message, user_message) - return RefineToolExecutionOutcome( - patch_selected=True, - memory_selected=False, - memory_queued=False, - fallback_patch_used=False, - calendar=CalendarSyncOutcome( - status="staged", - changed=True, - note="Plan updated locally. Review Stage 5 and click Submit to sync to calendar.", - ), - ) - - def _fastpath_gate(_self, *, session: Session) -> StageGateOutput: - _ = session - return StageGateOutput( - stage_id=TimeboxingStage.REFINE, - ready=True, - summary=["Patch applied locally and staged for review."], - missing=[], - question="Reply with edits, or say `commit now`.", - facts={}, - ) - - agent._ensure_calendar_immovables = types.MethodType( # type: ignore[attr-defined] - lambda self, session: asyncio.sleep(0), - agent, - ) - agent._ensure_refine_plan_state = types.MethodType( # type: ignore[attr-defined] - _ensure_refine, - agent, - ) - agent._compose_patcher_message = types.MethodType( # type: ignore[attr-defined] - lambda self, **kwargs: kwargs["base_message"], - agent, - ) - agent._run_refine_tool_orchestration = types.MethodType( # type: ignore[attr-defined] - lambda self, **kwargs: _run_orchestration(**kwargs), - agent, - ) - agent._remaining_graph_turn_budget_s = types.MethodType( # type: ignore[attr-defined] - lambda self, _session: 5.0, - agent, - ) - agent._build_refine_budget_fastpath_gate = types.MethodType( # type: ignore[attr-defined] - _fastpath_gate, - agent, - ) - agent._session_debug = types.MethodType( # type: ignore[attr-defined] - lambda self, *_args, **_kwargs: None, - agent, - ) - agent._run_timebox_summary = AsyncMock() # type: ignore[attr-defined] - - transition = TransitionNode.__new__(TransitionNode) - transition.stage_user_message = "tighten deep work blocks" - transition.decision = None - - node = StageRefineNode( - orchestrator=agent, - session=session, - transition=transition, - ) - - await node.on_messages( - [TextMessage(content="tighten deep work blocks", source="user")], - CancellationToken(), - ) - - agent._run_timebox_summary.assert_not_awaited() # type: ignore[attr-defined] - assert node.last_gate is not None - assert node.last_gate.ready is True - assert "Patch applied locally" in node.last_gate.summary[0] - assert any("Plan updated locally" in line for line in node.last_gate.summary) - - -class TestRefineQualityFacts: - """Verify Refine stage quality facts are typed and persisted on Session.""" - - @pytest.mark.asyncio - async def test_enrich_refine_quality_feedback_uses_typed_llm_facts(self) -> None: - """When quality facts are absent, the LLM assessor should populate them.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - timebox = Timebox( - events=[ - CalendarEvent( - summary="Focus", - event_type=EventType.DEEP_WORK, - start_time=time(9, 0), - end_time=time(10, 30), - ) - ], - date=date(2026, 2, 13), - timezone="Europe/Amsterdam", - ) - gate = StageGateOutput( - stage_id=TimeboxingStage.REFINE, - ready=True, - summary=["Updated schedule."], - missing=[], - question="Proceed?", - facts={}, - ) - - async def _quality_assess(*, timebox: Timebox) -> RefineQualityFacts: - _ = timebox - return RefineQualityFacts( - quality_level=2, - quality_label="Okay", - missing_for_next=["more buffers"], - next_suggestion="Add a short recovery block after deep work.", - ) - - agent._run_refine_quality_assessment = types.MethodType( # type: ignore[attr-defined] - lambda self, **kwargs: _quality_assess(**kwargs), - agent, - ) - - enriched = await TimeboxingFlowAgent._enrich_refine_quality_feedback( - agent, - session=session, - gate=gate, - timebox=timebox, - ) - - assert enriched.ready is True - assert enriched.facts["quality_level"] == 2 - assert enriched.facts["quality_label"] == "Okay" - assert enriched.facts["next_suggestion"] == "Add a short recovery block after deep work." - assert session.last_quality_level == 2 - assert session.last_quality_label == "Okay" - assert session.last_quality_next_step == "Add a short recovery block after deep work." - - -class TestRefinePreparation: - """Verify Stage 4 preflight preparation when Stage 3 is markdown-only.""" - - def test_ensure_refine_plan_state_builds_plan_and_snapshot(self) -> None: - """When missing, Stage 4 should derive TBPlan and baseline snapshot.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-02-13", - tz_name="Europe/Amsterdam", - timebox=Timebox( - events=[ - CalendarEvent( - summary="Focus Block", - event_type=EventType.DEEP_WORK, - start_time=time(9, 0), - end_time=time(10, 30), - ) - ], - date=date(2026, 2, 13), - timezone="Europe/Amsterdam", - ), - ) - - agent._session_debug = types.MethodType( # type: ignore[attr-defined] - lambda self, *_args, **_kwargs: None, - agent, - ) - agent._build_remote_snapshot_plan = types.MethodType( # type: ignore[attr-defined] - lambda self, _session: TBPlan( - date=date(2026, 2, 13), - tz="Europe/Amsterdam", - events=[], - ), - agent, - ) - - TimeboxingFlowAgent._ensure_refine_plan_state(agent, session) - - assert session.tb_plan is not None - assert len(session.tb_plan.events) == 1 - assert session.base_snapshot is not None - assert session.base_snapshot.events == [] - - def test_ensure_refine_plan_state_returns_issue_for_unanchored_seed(self) -> None: - """Stage 4 preflight should keep an editable seed and surface repair issue.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-02-13", - tz_name="Europe/Amsterdam", - timebox=Timebox.model_construct( - events=[ - CalendarEvent.model_construct( - summary="Busy", - event_type=EventType.MEETING, - start_time=None, - end_time=None, - duration=timedelta(minutes=45), - ) - ], - date=date(2026, 2, 13), - timezone="Europe/Amsterdam", - ), - ) - - agent._session_debug = types.MethodType( # type: ignore[attr-defined] - lambda self, *_args, **_kwargs: None, - agent, - ) - agent._build_remote_snapshot_plan = types.MethodType( # type: ignore[attr-defined] - lambda self, _session: TBPlan( - date=date(2026, 2, 13), - tz="Europe/Amsterdam", - events=[], - ), - agent, - ) - - preflight = TimeboxingFlowAgent._ensure_refine_plan_state(agent, session) - - assert preflight.has_plan_issues - assert "timebox_to_tb_plan" in preflight.plan_issues[0] - assert session.tb_plan is not None - assert len(session.tb_plan.events) == 1 - - -class TestRefineStageGuards: - """Verify patching is guarded to Stage 4 Refine only.""" - - def test_compose_patcher_message_rejects_non_refine_stage(self) -> None: - """Coordinator should reject patcher payloads outside Refine stage.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-02-13", - tz_name="Europe/Amsterdam", - ) - - with pytest.raises(ValueError, match="restricted to Stage 4 Refine"): - TimeboxingFlowAgent._compose_patcher_message( - agent, - base_message="test", - session=session, - stage=TimeboxingStage.SKELETON.value, - ) - - @pytest.mark.asyncio - async def test_update_timebox_with_feedback_noops_outside_refine(self) -> None: - """Legacy feedback updater should not patch unless stage is Refine.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-02-13", - tz_name="Europe/Amsterdam", - stage=TimeboxingStage.SKELETON, - tb_plan=TBPlan( - date=date(2026, 2, 13), - tz="Europe/Amsterdam", - events=[ - TBEvent( - n="Anchor", - t=ET.M, - p=FixedWindow(st=time(9, 0), et=time(10, 0)), - ) - ], - ), - ) - agent._timebox_patcher = types.SimpleNamespace( - apply_patch=lambda **_kwargs: (_ for _ in ()).throw( - AssertionError("Patcher must not run outside Refine.") - ), - apply_patch_legacy=lambda **_kwargs: (_ for _ in ()).throw( - AssertionError("Legacy patcher must not run outside Refine.") - ), - ) - - actions = await TimeboxingFlowAgent._update_timebox_with_feedback( - agent, - session, - "move things around", - ) - - assert actions == [] diff --git a/tests/unit/timeboxing/test_skeleton.py b/tests/unit/timeboxing/test_skeleton.py deleted file mode 100644 index b7a1c2fd..00000000 --- a/tests/unit/timeboxing/test_skeleton.py +++ /dev/null @@ -1,346 +0,0 @@ -"""The skeleton: the context it is built from, how Stage 3 drafts it, -what happens when drafting times out, and Stage 2 pre-generation. -""" - -from __future__ import annotations - -import asyncio -import types -import pytest -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.preferences import ( - Constraint, - ConstraintNecessity, - ConstraintSource, - ConstraintStatus, -) -from fateforger.agents.timeboxing.stage_gating import TimeboxingStage -from datetime import date, time -from typing import Any -from fateforger.agents.timeboxing.contracts import SkeletonContext -from fateforger.agents.timeboxing.tb_models import ET, FixedWindow, TBEvent, TBPlan -from datetime import date -from autogen_ext.models.openai import OpenAIChatCompletionClient -from datetime import date, time, timedelta -from autogen_agentchat.messages import TextMessage -from autogen_core import CancellationToken -from fateforger.agents.schedular.models.calendar import CalendarEvent, EventType -from fateforger.agents.timeboxing.nodes.nodes import StageSkeletonNode, TransitionNode -from fateforger.agents.timeboxing.timebox import Timebox - - -pytest.importorskip("autogen_agentchat") - - -# ── the skeleton context the coordinator builds ─────────────────────────────── - -async def _noop_ensure_calendar(self, _session, *, timeout_s=0.0) -> None: - """Disable MCP calendar fetch for unit tests.""" - return None - - -@pytest.mark.asyncio -async def test_build_skeleton_context_includes_constraints_and_immovables() -> None: - """Ensure the coordinator injects constraints + immovables into SkeletonContext.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._constraint_store = None - agent._ensure_calendar_immovables = types.MethodType(_noop_ensure_calendar, agent) - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.planned_date = "2026-01-21" - session.tz_name = "Europe/Amsterdam" - session.stage = TimeboxingStage.SKELETON # constraints are scoped to active stage - session.frame_facts = { - "immovables": [{"title": "Gym", "start": "18:00", "end": "19:30"}] - } - session.durable_constraints_by_stage[TimeboxingStage.SKELETON.value] = [ - Constraint( - name="No calls after 17:00", - description="Avoid meetings after 17:00", - necessity=ConstraintNecessity.MUST, - user_id="u1", - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - ) - ] - - ctx = await agent._build_skeleton_context(session) - assert ctx.timezone == "Europe/Amsterdam" - assert ctx.immovables and ctx.immovables[0].title == "Gym" - assert ctx.constraints_snapshot - assert ctx.constraints_snapshot[0].name == "No calls after 17:00" - - -# ── Stage 3: markdown first, seed plan, no patcher ──────────────────────────── - -async def test_run_skeleton_draft_uses_markdown_and_seed_plan_only( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Stage 3 should draft markdown and carry a seed plan without patching.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-02-14", - tz_name="Europe/Amsterdam", - ) - agent._timebox_patcher = types.SimpleNamespace( - apply_patch=lambda **_kwargs: (_ for _ in ()).throw( - AssertionError("Stage 3 must not call patcher.") - ) - ) - - async def _noop_stage_agents(self: TimeboxingFlowAgent) -> None: - return None - - async def _overview( - self: TimeboxingFlowAgent, *, context: Any - ) -> str: - _ = context - return "## Day Overview\n### Morning\n- Deep Work (120 min)" - - async def _constraints(self: TimeboxingFlowAgent, _session: Session) -> list[Any]: - return [] - - async def _context( - self: TimeboxingFlowAgent, _session: Session - ) -> SkeletonContext: - return SkeletonContext( - date=date(2026, 2, 14), - timezone="Europe/Amsterdam", - ) - - def _seed(self: TimeboxingFlowAgent, _session: Session) -> TBPlan: - return TBPlan( - date=date(2026, 2, 14), - tz="Europe/Amsterdam", - events=[ - TBEvent( - n="Anchor", - t=ET.M, - p=FixedWindow(st=time(9, 0), et=time(10, 0)), - ) - ], - ) - - monkeypatch.setattr(TimeboxingFlowAgent, "_ensure_stage_agents", _noop_stage_agents) - monkeypatch.setattr(TimeboxingFlowAgent, "_run_skeleton_overview_markdown", _overview) - monkeypatch.setattr(TimeboxingFlowAgent, "_build_skeleton_context", _context) - monkeypatch.setattr(TimeboxingFlowAgent, "_build_skeleton_seed_plan", _seed) - monkeypatch.setattr(TimeboxingFlowAgent, "_collect_constraints", _constraints) - - drafted_timebox, markdown, drafted_plan = await TimeboxingFlowAgent._run_skeleton_draft( - agent, session - ) - - assert markdown.startswith("## Day Overview") - assert drafted_timebox is None - assert drafted_plan is not None - assert drafted_plan.events[0].n == "Anchor" - - -# ── when drafting times out ─────────────────────────────────────────────────── - -class DummyDraftAgent: - """Minimal draft agent stub for timeout fallback tests.""" - - async def on_messages(self, *_args: Any, **_kwargs: Any) -> None: - """Return no content because the timeout is injected.""" - return None - - -async def _noop_ensure_stage_agents(self: TimeboxingFlowAgent) -> None: - """No-op stage agent initializer for testing.""" - return None - - -async def _noop_calendar_immovables( - self: TimeboxingFlowAgent, _session: Session, *, timeout_s: float = 0.0 -) -> None: - """Skip calendar MCP fetch in unit tests.""" - return None - - -async def _timeout_with_timeout( - _label: str, awaitable: Any, *, timeout_s: float -) -> None: - """Raise a timeout to trigger the fallback path.""" - if hasattr(awaitable, "close"): - awaitable.close() - raise asyncio.TimeoutError - - -@pytest.mark.asyncio -async def test_skeleton_draft_timeout_fallback(monkeypatch) -> None: - """Return a minimal timebox when skeleton drafting times out.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._draft_agent = DummyDraftAgent() - agent._draft_model_client = OpenAIChatCompletionClient(model="gpt-4o-mini", api_key="test") - agent._ensure_stage_agents = types.MethodType(_noop_ensure_stage_agents, agent) - agent._constraint_store = None - agent._ensure_calendar_immovables = types.MethodType(_noop_calendar_immovables, agent) - - monkeypatch.setattr( - "fateforger.agents.timeboxing.agent.with_timeout", _timeout_with_timeout - ) - - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-01-21", - tz_name="Europe/Amsterdam", - ) - - timebox, markdown, plan = await agent._run_skeleton_draft(session) - - assert timebox is None - assert plan is not None - assert plan.date == date(2026, 1, 21) - assert plan.tz == "Europe/Amsterdam" - assert len(plan.events) >= 1 - assert markdown.startswith("## Day Overview") - assert any("deterministic fallback" in msg.lower() for msg in session.background_updates) - - -# ── Stage 2 pre-generation ──────────────────────────────────────────────────── - -async def test_stage_skeleton_uses_pre_generated_draft_without_llm() -> None: - """Use ``session.pre_generated_skeleton`` and skip synchronous LLM drafting.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - - pre_generated = Timebox( - events=[ - CalendarEvent( - summary="Focus Block", - event_type=EventType.DEEP_WORK, - start_time=time(9, 0), - duration=timedelta(minutes=90), - ) - ], - date=date(2026, 2, 13), - timezone="Europe/Amsterdam", - ) - - async def _should_not_run_draft(_session: Session) -> tuple[None, str, TBPlan | None]: - raise AssertionError("Synchronous skeleton draft should not run.") - agent._build_remote_snapshot_plan = types.MethodType( # type: ignore[attr-defined] - lambda self, _session: None, - agent, - ) - agent._render_markdown_summary_blocks = types.MethodType( # type: ignore[attr-defined] - lambda self, text: [], - agent, - ) - agent._run_skeleton_draft = types.MethodType( # type: ignore[attr-defined] - lambda self, session: _should_not_run_draft(session), - agent, - ) - - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-02-13", - tz_name="Europe/Amsterdam", - frame_facts={"immovables": [{"title": "Meeting", "start": "10:00", "end": "11:00"}]}, - input_facts={"block_plan": {"deep_blocks": 2}}, - ) - session.pre_generated_skeleton = pre_generated - session.pre_generated_skeleton_plan = TBPlan( - date=date(2026, 2, 13), - tz="Europe/Amsterdam", - events=[ - TBEvent( - n="Focus Block", - t=ET.DW, - p=FixedWindow(st=time(9, 0), et=time(10, 30)), - ) - ], - ) - session.pre_generated_skeleton_markdown = "## Day Overview\n- Focus Block" - session.pre_generated_skeleton_fingerprint = ( - agent._skeleton_pregeneration_fingerprint(session) # type: ignore[attr-defined] - ) - - transition = TransitionNode.__new__(TransitionNode) - transition.stage_user_message = "" - transition.decision = None - - node = StageSkeletonNode( - orchestrator=agent, - session=session, - transition=transition, - ) - await node.on_messages( - [TextMessage(content="go", source="user")], - CancellationToken(), - ) - - assert session.timebox is None - assert session.tb_plan is not None - assert session.base_snapshot is None - assert session.skeleton_overview_markdown == "## Day Overview\n- Focus Block" - assert session.stage_ready is True - assert session.last_response == "Stage 3/5 (Skeleton)\nOverview ready below." - assert session.pre_generated_skeleton is None - assert session.pre_generated_skeleton_plan is None - assert session.pre_generated_skeleton_markdown is None - - -@pytest.mark.asyncio -async def test_consume_pre_generated_skeleton_waits_for_inflight_task( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Consume should await matching in-flight pre-generation before drafting sync.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session( - thread_ts="t2", - channel_id="c1", - user_id="u1", - planned_date="2026-02-14", - tz_name="Europe/Amsterdam", - frame_facts={"immovables": [{"title": "Meeting", "start": "10:00", "end": "11:00"}]}, - input_facts={"block_plan": {"deep_blocks": 2}}, - ) - expected_plan = TBPlan( - date=date(2026, 2, 14), - tz="Europe/Amsterdam", - events=[ - TBEvent( - n="Focus Block", - t=ET.DW, - p=FixedWindow(st=time(9, 0), et=time(10, 30)), - ) - ], - ) - session.pre_generated_skeleton_fingerprint = ( - agent._skeleton_pregeneration_fingerprint(session) # type: ignore[attr-defined] - ) - - async def _background_complete() -> None: - await asyncio.sleep(0.01) - session.pre_generated_skeleton_plan = expected_plan - session.pre_generated_skeleton_markdown = "## Day Overview\n- Focus Block" - - session.pre_generated_skeleton_task = asyncio.create_task(_background_complete()) - - async def _should_not_run_sync_draft( - self: TimeboxingFlowAgent, current: Session - ) -> tuple[None, str, TBPlan | None]: - _ = (self, current) - raise AssertionError("Synchronous skeleton draft should not run.") - - monkeypatch.setattr( - TimeboxingFlowAgent, - "_run_skeleton_draft", - _should_not_run_sync_draft, - ) - - _timebox, markdown, drafted_plan = await TimeboxingFlowAgent._consume_pre_generated_skeleton( - agent, session - ) - - assert drafted_plan is expected_plan - assert markdown == "## Day Overview\n- Focus Block" diff --git a/tests/unit/timeboxing/test_slack_channel_default_routing.py b/tests/unit/timeboxing/test_slack_channel_default_routing.py index abf3a8b5..2b1ce5ed 100644 --- a/tests/unit/timeboxing/test_slack_channel_default_routing.py +++ b/tests/unit/timeboxing/test_slack_channel_default_routing.py @@ -7,7 +7,6 @@ from fateforger.core.config import settings from fateforger.slack_bot.focus import FocusManager from fateforger.slack_bot.handlers import route_slack_event -from fateforger.agents.timeboxing.messages import StartTimeboxing from tests.doubles.slack import RecordingSlackClient @@ -27,7 +26,7 @@ async def _unused_say(**_kwargs): @pytest.mark.asyncio -async def test_specialist_channel_routes_directly_to_timeboxing_agent(monkeypatch): +async def test_the_specialist_channel_opens_a_session_directly(monkeypatch): monkeypatch.setattr(settings, "slack_timeboxing_channel_id", "C_PLAN", raising=False) runtime = DummyRuntime() client = RecordingSlackClient() @@ -43,8 +42,5 @@ async def test_specialist_channel_routes_directly_to_timeboxing_agent(monkeypatc client=client, ) - assert len(runtime.calls) == 1 - msg, recipient = runtime.calls[0] - assert recipient.type == "timeboxing_agent" - assert isinstance(msg, StartTimeboxing) - + assert runtime.calls == [] + assert any(p.get("channel") == "C_PLAN" and not p.get("thread_ts") for p in client.posted) diff --git a/tests/unit/timeboxing/test_slack_timeboxing_channel_redirect.py b/tests/unit/timeboxing/test_slack_timeboxing_channel_redirect.py index bdf892a3..662eb3d0 100644 --- a/tests/unit/timeboxing/test_slack_timeboxing_channel_redirect.py +++ b/tests/unit/timeboxing/test_slack_timeboxing_channel_redirect.py @@ -129,6 +129,71 @@ async def __call__(self, **payload): return {"channel": "C_ORIG", "ts": f"orig_proc_{len(self.calls)}"} +class _FailsForChannelClient(RecordingSlackClient): + """Raises on `chat_postMessage` for one channel. + + The bot not being a member of the configured session channel is the + ordinary cause: `open_session_surface` posts its root before + `_begin_timeboxing_session_surface`'s own try/except, so this is what + that failure looks like from the caller's side. + """ + + def __init__(self, *, fails_for: str, **kwargs) -> None: + super().__init__(**kwargs) + self._fails_for = fails_for + + async def chat_postMessage(self, **payload): + if payload.get("channel") == self._fails_for: + raise RuntimeError("channel_not_found") + return await super().chat_postMessage(**payload) + + +@pytest.mark.asyncio +async def test_a_failed_root_post_in_the_configured_channel_falls_back_to_the_origin_channel( + monkeypatch, +): + """The bot not being in #timeboxing must not lose the session, or fall + through to the retired runtime send. + """ + monkeypatch.setattr( + settings, "slack_timeboxing_channel_id", "C_TIMEBOX", raising=False + ) + + runtime = DummyRuntime() + client = _FailsForChannelClient( + fails_for="C_TIMEBOX", root_ts="orig_root", reply_ts="orig_proc", dm_channel="D_DM" + ) + say = DummySay() + focus = FocusManager( + ttl_seconds=3600, allowed_agents=["receptionist_agent", "timeboxing_agent"] + ) + turns: list[dict] = [] + + async def _fake_turn(**kwargs): + turns.append(kwargs) + return SlackBlockMessage(text="turn ran", blocks=[]) + + monkeypatch.setattr(handlers, "_run_adaptive_timebox_turn", _fake_turn) + + event = {"channel": "C_ORIG", "user": "U1", "text": "timebox tomorrow", "ts": "1"} + await route_slack_event( + runtime=runtime, + focus=focus, + default_agent="receptionist_agent", + event=event, + bot_user_id=None, + say=say, + client=client, + ) + + assert [r.type for _, r in runtime.calls] == ["receptionist_agent"] + assert not any(p.get("channel") == "C_TIMEBOX" for p in client.posted) + assert any( + u.get("channel") == "C_ORIG" and u.get("ts") == "orig_root" for u in client.updates + ) + assert turns and turns[0]["session_key"] == "C_ORIG:orig_root" + + @pytest.mark.asyncio async def test_timeboxing_handoff_redirects_into_configured_channel(monkeypatch): monkeypatch.setattr( @@ -153,11 +218,11 @@ async def test_timeboxing_handoff_redirects_into_configured_channel(monkeypatch) client=client, ) - assert [r.type for _, r in runtime.calls] == [ - "receptionist_agent", - "timeboxing_agent", - ] - assert runtime.calls[1][1].key == "C_TIMEBOX:tb_root" + assert [r.type for _, r in runtime.calls] == ["receptionist_agent"] + assert focus.get_redirect("C_ORIG:1").target_key == "C_TIMEBOX:tb_root" + assert any( + p.get("channel") == "C_TIMEBOX" and not p.get("thread_ts") for p in client.posted + ) # Thread root + processing reply in the timeboxing channel assert any( p["channel"] == "C_TIMEBOX" and not p.get("thread_ts") for p in client.posted @@ -166,12 +231,10 @@ async def test_timeboxing_handoff_redirects_into_configured_channel(monkeypatch) p["channel"] == "C_TIMEBOX" and p.get("thread_ts") == "tb_root" for p in client.posted ) - # User gets a DM with the commit prompt (best-effort). - # Note: "Go to session" is NOT included initially - it appears after user clicks Confirm. + # User gets a DM linking back to the session thread (best-effort). assert client.opened and client.opened[0]["users"] == ["U1"] assert any( - p["channel"] == "D_DM" - and FF_TIMEBOX_COMMIT_START_ACTION_ID in str(p.get("blocks")) + p["channel"] == "D_DM" and "ff_open_thread" in str(p.get("blocks")) for p in client.posted ) @@ -189,7 +252,7 @@ async def test_timeboxing_handoff_redirects_into_configured_channel(monkeypatch) @pytest.mark.asyncio -async def test_timeboxing_reply_in_origin_thread_is_forwarded(monkeypatch): +async def test_a_reply_in_the_origin_thread_continues_the_session_on_the_kernel(monkeypatch): monkeypatch.setattr( settings, "slack_timeboxing_channel_id", "C_TIMEBOX", raising=False ) @@ -200,6 +263,13 @@ async def test_timeboxing_reply_in_origin_thread_is_forwarded(monkeypatch): focus = FocusManager( ttl_seconds=3600, allowed_agents=["receptionist_agent", "timeboxing_agent"] ) + turns: list[dict] = [] + + async def _fake_turn(**kwargs): + turns.append(kwargs) + return SlackBlockMessage(text="turn ran", blocks=[]) + + monkeypatch.setattr(handlers, "_run_adaptive_timebox_turn", _fake_turn) # First message creates redirect + focus await route_slack_event( @@ -212,7 +282,7 @@ async def test_timeboxing_reply_in_origin_thread_is_forwarded(monkeypatch): client=client, ) - # Reply in the original thread should be forwarded to the timeboxing thread + # Reply in the original thread should continue the session on the kernel await route_slack_event( runtime=runtime, focus=focus, @@ -229,9 +299,9 @@ async def test_timeboxing_reply_in_origin_thread_is_forwarded(monkeypatch): client=client, ) - # The last runtime call is a timeboxing_agent call keyed to the timeboxing thread - assert runtime.calls[-1][1].type == "timeboxing_agent" - assert runtime.calls[-1][1].key == "C_TIMEBOX:tb_root" + assert [r.type for _, r in runtime.calls] == ["receptionist_agent"] + assert [t["session_key"] for t in turns] == ["C_TIMEBOX:tb_root", "C_TIMEBOX:tb_root"] + assert any(u.get("channel") == "C_TIMEBOX" for u in client.updates) @pytest.mark.asyncio @@ -243,6 +313,13 @@ async def test_timeboxing_done_updates_thread_header_emoji(monkeypatch): runtime = DoneRuntime() client = RecordingSlackClient(root_ts="tb_root", reply_ts="tb_proc", dm_channel="D_DM") say = DummySay() + + async def _fake_turn(**_kwargs): + return SlackThreadStateMessage(text="Finalized.", thread_state="done") + + monkeypatch.setattr( + "fateforger.slack_bot.handlers._run_adaptive_timebox_turn", _fake_turn + ) focus = FocusManager( ttl_seconds=3600, allowed_agents=["receptionist_agent", "timeboxing_agent"] ) @@ -370,7 +447,6 @@ async def test_harness_redirect_offers_the_owned_approval_in_the_timeboxing_thre monkeypatch.setattr( settings, "slack_timeboxing_channel_id", "C_TIMEBOX", raising=False ) - monkeypatch.setenv("FF_TIMEBOX_BACKEND", "harness") monkeypatch.setattr( handlers, "_pending_candidates", PendingTimeboxCandidates() ) diff --git a/tests/unit/timeboxing/test_slack_timeboxing_routing.py b/tests/unit/timeboxing/test_slack_timeboxing_routing.py index 3db9b8a7..bba72499 100644 --- a/tests/unit/timeboxing/test_slack_timeboxing_routing.py +++ b/tests/unit/timeboxing/test_slack_timeboxing_routing.py @@ -7,7 +7,6 @@ from autogen_agentchat.messages import TextMessage from autogen_core import AgentId -from fateforger.agents.timeboxing.messages import StartTimeboxing, TimeboxingUserReply from fateforger.slack_bot.focus import FocusManager from fateforger.slack_bot.handlers import _with_agent_attribution, route_slack_event from fateforger.slack_bot.messages import SlackBlockMessage @@ -136,7 +135,7 @@ async def _route(*, runtime, focus, client, planning, event): @pytest.mark.asyncio -async def test_routes_root_message_to_timeboxing_start_when_focused(): +async def test_a_root_message_in_a_focused_channel_opens_a_session(monkeypatch): focus = FocusManager(ttl_seconds=60, allowed_agents=["timeboxing_agent"]) focus.set_focus("C1:111", "timeboxing_agent", by_user="U1") runtime = _FakeRuntime([_FakeResult(TextMessage(content="ok", source="bot"))]) @@ -152,25 +151,18 @@ async def test_routes_root_message_to_timeboxing_start_when_focused(): client=client, ) - assert len(runtime.calls) == 1 - msg, recipient = runtime.calls[0] - assert isinstance(msg, StartTimeboxing) - # Root timeboxing sessions are anchored to the bot's prompt message (not the user's message), - # so the session thread can start cleanly under a deterministic control surface. - assert msg.thread_ts == "p1" - assert recipient.type == "timeboxing_agent" - assert recipient.key == "C1:p1" + assert runtime.calls == [] + assert any(p.get("channel") == "C1" and not p.get("thread_ts") for p in client.posted) @pytest.mark.asyncio -async def test_handoff_from_receptionist_resends_as_timeboxing_start(): +async def test_a_receptionist_handoff_opens_a_session_where_the_user_is(monkeypatch): focus = FocusManager( ttl_seconds=60, allowed_agents=["receptionist_agent", "timeboxing_agent"] ) runtime = _FakeRuntime( [ _FakeResult(_FakeHandoffMessage("timeboxing_agent")), - _FakeResult(TextMessage(content="ok", source="bot")), ] ) client = _FakeClient() @@ -185,24 +177,28 @@ async def test_handoff_from_receptionist_resends_as_timeboxing_start(): client=client, ) - assert len(runtime.calls) == 2 - first_msg, first_recipient = runtime.calls[0] - second_msg, second_recipient = runtime.calls[1] - - assert isinstance(first_msg, TextMessage) - assert first_recipient.type == "receptionist_agent" - - assert isinstance(second_msg, StartTimeboxing) - assert second_msg.thread_ts == "222" - assert second_recipient.type == "timeboxing_agent" + assert [r.type for _, r in runtime.calls] == ["receptionist_agent"] + # No timeboxing channel is configured, so the session lives in C1, the + # channel the user is already in: the origin "thinking..." ack (posted by + # `_FakeClient.chat_postMessage`, which always answers with ts "p1") is + # repurposed into the root via `chat_update`, rather than left beside a + # freshly-posted second root. + assert any(u.get("channel") == "C1" and u.get("ts") == "p1" for u in client.updates) @pytest.mark.asyncio -async def test_routes_thread_reply_to_timeboxing_user_reply(): +async def test_a_thread_reply_in_a_focused_thread_is_a_kernel_turn(monkeypatch): focus = FocusManager(ttl_seconds=60, allowed_agents=["timeboxing_agent"]) focus.set_focus("C1:root", "timeboxing_agent", by_user="U1") runtime = _FakeRuntime([_FakeResult(TextMessage(content="ok", source="bot"))]) client = _FakeClient() + turns: list[dict] = [] + + async def _fake_turn(**kwargs): + turns.append(kwargs) + return SlackBlockMessage(text="turn ran", blocks=[]) + + monkeypatch.setattr("fateforger.slack_bot.handlers._run_adaptive_timebox_turn", _fake_turn) await route_slack_event( runtime=runtime, @@ -220,10 +216,9 @@ async def test_routes_thread_reply_to_timeboxing_user_reply(): client=client, ) - assert len(runtime.calls) == 1 - msg, _ = runtime.calls[0] - assert isinstance(msg, TimeboxingUserReply) - assert msg.thread_ts == "root" + assert runtime.calls == [] + assert [t["session_key"] for t in turns] == ["C1:root"] + assert client.updates, "the turn's outcome is written back into the thread" @pytest.mark.asyncio @@ -272,9 +267,16 @@ async def test_route_slack_event_compacts_payload_after_msg_too_long(monkeypatch @pytest.mark.asyncio async def test_route_slack_event_records_stage_compute_failure(monkeypatch): - class _FailingRuntime: + """A turn that blows up is recorded and named back into the thread. + + The failing call is the kernel turn: timeboxing has not gone through + ``runtime.send_message`` since the legacy agent was retired, and this + asserts the handler's own except arm, which is shared by both. + """ + + class _UnusedRuntime: async def send_message(self, *_args, **_kwargs): - raise RuntimeError("compute blew up") + raise AssertionError("timeboxing does not go through the runtime") focus = FocusManager(ttl_seconds=60, allowed_agents=["timeboxing_agent"]) focus.set_focus("C1:root", "timeboxing_agent", by_user="U1") @@ -285,74 +287,32 @@ async def send_message(self, *_args, **_kwargs): lambda *, component, error_type: errors.append((component, error_type)), ) - await route_slack_event( - runtime=_FailingRuntime(), - focus=focus, - default_agent="receptionist_agent", - event={ - "channel": "C1", - "user": "U1", - "text": "reply", - "thread_ts": "root", - "ts": "444", - }, - bot_user_id=None, - say=_unused_say, - client=client, - ) - - assert ("slack_routing", "stage_compute_failure") in errors - assert client.updates - assert "RuntimeError" in (client.updates[-1].get("text") or "") - + async def _failing_turn(**_kwargs): + raise RuntimeError("compute blew up") -@pytest.mark.asyncio -async def test_route_slack_event_constraint_refresh_failure_is_non_fatal(monkeypatch): - class _ExplodingConstraintStore: - async def list_constraints(self, **_kwargs): - raise RuntimeError("constraint store unavailable") - - focus = FocusManager(ttl_seconds=60, allowed_agents=["timeboxing_agent"]) - key = "C1:root" - focus.set_focus(key, "timeboxing_agent", by_user="U1") - focus.set_thread_label( - key, - title="Timeboxing session", - request_excerpt=None, - state="pending", - by_user="U1", - ) - runtime = _FakeRuntime([_FakeResult(TextMessage(content="ok", source="bot"))]) - client = _FakeClient() - errors: list[tuple[str, str]] = [] monkeypatch.setattr( - "fateforger.slack_bot.handlers.record_error", - lambda *, component, error_type: errors.append((component, error_type)), + "fateforger.slack_bot.handlers._run_adaptive_timebox_turn", _failing_turn ) - async def _get_constraint_store(): - return _ExplodingConstraintStore() - await route_slack_event( - runtime=runtime, + runtime=_UnusedRuntime(), focus=focus, - default_agent="timeboxing_agent", + default_agent="receptionist_agent", event={ "channel": "C1", "user": "U1", "text": "reply", "thread_ts": "root", - "ts": "555", + "ts": "444", }, bot_user_id=None, say=_unused_say, client=client, - get_constraint_store=_get_constraint_store, ) + assert ("slack_routing", "stage_compute_failure") in errors assert client.updates - assert str(client.updates[-1].get("text") or "").endswith("ok") - assert ("slack_routing", "constraint_refresh_error") in errors + assert "RuntimeError" in (client.updates[-1].get("text") or "") @pytest.mark.asyncio @@ -442,7 +402,6 @@ async def _fake_turn(**kwargs): return SlackBlockMessage(text="turn ran", blocks=[]) monkeypatch.setattr("fateforger.slack_bot.handlers._run_adaptive_timebox_turn", _fake_turn) - monkeypatch.setattr("fateforger.slack_bot.handlers._timebox_backend", lambda: "harness") await _route(runtime=runtime, focus=focus, client=client, planning=planning, event=_dm_reply_event("move gym to 19:00")) diff --git a/tests/unit/timeboxing/test_stage_decisions.py b/tests/unit/timeboxing/test_stage_decisions.py deleted file mode 100644 index 66cefc72..00000000 --- a/tests/unit/timeboxing/test_stage_decisions.py +++ /dev/null @@ -1,264 +0,0 @@ -"""How a stage decides what comes next: the decision node, the fallback -when the gate cannot answer, and the JSON context the gate is given. -""" - -from __future__ import annotations - -from types import SimpleNamespace -import pytest -from fateforger.agents.timeboxing import agent as timeboxing_agent_module -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.stage_gating import TimeboxingStage -from dataclasses import dataclass -from typing import Any - - -pytest.importorskip("autogen_agentchat") - - -# ── the decision node ───────────────────────────────────────────────────────── - -from autogen_core import CancellationToken - -from fateforger.agents.timeboxing.agent import Session -from fateforger.agents.timeboxing.nodes.nodes import DecisionNode, TurnContext - - -class _OrchestratorStub: - async def _decide_next_action(self, *_args, **_kwargs): - raise AssertionError("Decision LLM path should not run when force rerun is set.") - - -@pytest.mark.asyncio -async def test_decision_node_respects_force_stage_rerun_flag() -> None: - """DecisionNode should consume `force_stage_rerun` without touching LLM routing.""" - session = Session(thread_ts="T1", channel_id="C1", user_id="U1") - session.force_stage_rerun = True - turn_init = SimpleNamespace(turn=TurnContext(user_text="Proceed.")) - node = DecisionNode( - orchestrator=_OrchestratorStub(), - session=session, - turn_init=turn_init, - ) - - await node.on_messages([], CancellationToken()) - - assert turn_init.turn.decision is not None - assert turn_init.turn.decision.action == "redo" - assert turn_init.turn.decision.note == "stage_action_rerun" - assert session.force_stage_rerun is False - - -# ── falling back when the gate cannot decide ────────────────────────────────── - -class _DecisionAgentStub: - async def on_messages(self, *_args, **_kwargs): - return object() - - -@pytest.mark.asyncio -async def test_decide_next_action_timeout_returns_provide_info( - monkeypatch: pytest.MonkeyPatch, -) -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - # _build_one_shot_agent is called per-turn; point it at a stub instead of a real LLM. - agent._build_one_shot_agent = lambda *_a, **_kw: _DecisionAgentStub() - agent._session_debug_loggers = {} - - async def _raise_timeout(_label, awaitable, *, timeout_s, **_kwargs): - _ = timeout_s - close = getattr(awaitable, "close", None) - if callable(close): - close() - raise TimeoutError("decision timeout") - - monkeypatch.setattr(timeboxing_agent_module, "with_timeout", _raise_timeout) - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", committed=True) - session.stage = TimeboxingStage.CAPTURE_INPUTS - - decision = await TimeboxingFlowAgent._decide_next_action( - agent, - session, - user_message="continue", - ) - - assert decision.action == "provide_info" - assert decision.note == "stage_decision_timeout" - - -@pytest.mark.asyncio -async def test_decide_next_action_parse_error_returns_provide_info( - monkeypatch: pytest.MonkeyPatch, -) -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - # _build_one_shot_agent is called per-turn; point it at a stub instead of a real LLM. - agent._build_one_shot_agent = lambda *_a, **_kw: _DecisionAgentStub() - agent._session_debug_loggers = {} - - async def _return_dummy(_label, awaitable, *, timeout_s, **_kwargs): - _ = timeout_s - close = getattr(awaitable, "close", None) - if callable(close): - close() - return object() - - def _raise_parse(*_args, **_kwargs): - raise ValueError("bad parse") - - monkeypatch.setattr(timeboxing_agent_module, "with_timeout", _return_dummy) - monkeypatch.setattr(timeboxing_agent_module, "parse_chat_content", _raise_parse) - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", committed=True) - session.stage = TimeboxingStage.CAPTURE_INPUTS - - decision = await TimeboxingFlowAgent._decide_next_action( - agent, - session, - user_message="continue", - ) - - assert decision.action == "provide_info" - assert decision.note == "stage_decision_parse_error" - - -# ── the JSON context the gate reads ─────────────────────────────────────────── - -from autogen_agentchat.messages import TextMessage - -from fateforger.agents.timeboxing.agent import TimeboxingFlowAgent -from fateforger.agents.timeboxing.stage_gating import StageGateOutput, TimeboxingStage - - -@dataclass -class _DummyChatMessage: - content: Any - - -@dataclass -class _DummyResponse: - chat_message: _DummyChatMessage - - -class _CapturingStageAgent: - """Fake stage agent that captures incoming messages and returns a fixed output.""" - - def __init__(self) -> None: - self.last_messages: list[TextMessage] = [] - - async def on_messages( - self, messages: list[TextMessage], _token: Any - ) -> _DummyResponse: - """Capture messages and return a minimal StageGateOutput.""" - self.last_messages = messages - return _DummyResponse( - chat_message=_DummyChatMessage( - content=StageGateOutput( - stage_id=TimeboxingStage.COLLECT_CONSTRAINTS, - ready=False, - summary=["ok"], - missing=["x"], - question="q", - facts={}, - ) - ) - ) - - -class _MalformedStageAgent: - """Fake stage agent returning malformed payload to test fallback behavior.""" - - async def on_messages( - self, messages: list[TextMessage], _token: Any - ) -> _DummyResponse: - _ = messages - return _DummyResponse(chat_message=_DummyChatMessage(content="not-json")) - - -@pytest.mark.asyncio -async def test_run_stage_gate_sends_strict_json_context( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Ensures `_run_stage_gate` injects list-shaped data via TOON tables.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - - async def _noop_ensure_stage_agents(self: TimeboxingFlowAgent) -> None: - """Avoid building real LLM agents in this unit test.""" - return None - - monkeypatch.setattr( - TimeboxingFlowAgent, "_ensure_stage_agents", _noop_ensure_stage_agents - ) - - capturing = _CapturingStageAgent() - # _build_one_shot_agent is called per invocation β€” return our capturing stub. - agent._build_one_shot_agent = lambda *_a, **_kw: capturing - agent._constraint_search_tool = None # accessed directly in _run_stage_gate - - context = { - "stage_id": "CollectConstraints", - "user_message": "hi", - "facts": {"k": 1}, - "durable_constraints": [ - { - "name": "Sleep target", - "description": "Aim for 8 hours", - "necessity": "should", - "status": "proposed", - "source": "system", - "scope": "profile", - "tags": [], - "hints": {}, - } - ], - } - out = await TimeboxingFlowAgent._run_stage_gate( # type: ignore[misc] - agent, - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - user_message="hi", - context=context, - ) - - assert out.stage_id == TimeboxingStage.COLLECT_CONSTRAINTS - assert capturing.last_messages, "Expected a single JSON message to be sent" - content = capturing.last_messages[0].content - assert "TOON format" in content - assert "facts_json:" in content - assert '"k": 1' in content - assert "immovables[0]{title,start,end}:" in content - assert ( - "durable_constraints[1]{name,necessity,scope,status,source,description}:" - in content - ) - - -@pytest.mark.asyncio -async def test_run_stage_gate_returns_safe_fallback_on_parse_failure( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Malformed model output should not crash stage execution.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - - async def _noop_ensure_stage_agents(self: TimeboxingFlowAgent) -> None: - return None - - monkeypatch.setattr( - TimeboxingFlowAgent, "_ensure_stage_agents", _noop_ensure_stage_agents - ) - # _build_one_shot_agent is called per invocation β€” return our malformed stub. - agent._build_one_shot_agent = lambda *_a, **_kw: _MalformedStageAgent() - agent._constraint_search_tool = None # accessed directly in _run_stage_gate - - context = {"facts": {"timezone": "Europe/Amsterdam"}} - out = await TimeboxingFlowAgent._run_stage_gate( # type: ignore[misc] - agent, - stage=TimeboxingStage.COLLECT_CONSTRAINTS, - user_message="use defaults", - context=context, - ) - - assert out.stage_id == TimeboxingStage.COLLECT_CONSTRAINTS - assert out.ready is False - assert "stage retry required" in out.missing - assert "_stage_gate_error" in out.facts - assert out.facts["timezone"] == "Europe/Amsterdam" diff --git a/tests/unit/timeboxing/test_stage_prompts.py b/tests/unit/timeboxing/test_stage_prompts.py deleted file mode 100644 index e9d1dfd9..00000000 --- a/tests/unit/timeboxing/test_stage_prompts.py +++ /dev/null @@ -1,57 +0,0 @@ -"""What the stage prompts must and must not say. - -These assert the contracts a prompt carries, not its phrasing: that it scopes -work in blocks rather than asking for durations, that it defaults -deterministically before reaching for search, and that it never names a tool -the agent does not have. A prompt may be reworded freely as long as those -hold. -""" - -from __future__ import annotations - -from fateforger.agents.timeboxing import stage_gating -from fateforger.agents.timeboxing.stage_gating import CAPTURE_INPUTS_PROMPT - - -# ── CaptureInputs: blocks, not durations ──────────────────────────────────── - - -def test_capture_inputs_prompt_prefers_block_scoping() -> None: - """Pin that CaptureInputs defaults to block-based scoping, not time estimates.""" - prompt = CAPTURE_INPUTS_PROMPT.lower() - assert "block_count" in prompt - assert "durations are optional" in prompt - assert "how long" not in prompt - assert "lead summary with what is still missing" in prompt - - -# ── the tools a prompt may name ───────────────────────────────────────────── - - -def test_stage_prompts_do_not_reference_forbidden_tools(): - """Stage prompts must not reference forbidden/nonexistent external tools.""" - for prompt_text in ( - stage_gating.COLLECT_CONSTRAINTS_PROMPT, - stage_gating.CAPTURE_INPUTS_PROMPT, - ): - lower = prompt_text.lower() - assert "list-events" not in lower - assert "ticktick" not in lower - assert "use your tools" not in lower - - -def test_collect_constraints_prompt_is_deterministic_first_with_fallback_search(): - """CollectConstraints should be deterministic-first with fallback search guidance.""" - prompt = stage_gating.COLLECT_CONSTRAINTS_PROMPT - assert "search_constraints" in prompt - assert "deterministic-first defaulting" in prompt.lower() - assert "injected durable constraints/defaults" in prompt.lower() - assert "fallback" in prompt.lower() - - -def test_capture_inputs_prompt_mentions_search_tool(): - """CaptureInputs prompt should mention search_constraints as optional.""" - prompt = stage_gating.CAPTURE_INPUTS_PROMPT - assert "search_constraints" in prompt - # Must NOT tell the agent the coordinator fetches in background (old instruction). - assert "coordinator will fetch in background" not in prompt diff --git a/tests/unit/timeboxing/test_stage_receipts_in_the_turn.py b/tests/unit/timeboxing/test_stage_receipts_in_the_turn.py index 2a6f99f1..869968e9 100644 --- a/tests/unit/timeboxing/test_stage_receipts_in_the_turn.py +++ b/tests/unit/timeboxing/test_stage_receipts_in_the_turn.py @@ -351,21 +351,14 @@ async def test_a_skeleton_older_than_its_contract_fails_the_turn( assert registry.shown("C1:1.0").ts == "100.1" -# -- The root is rewritten from the focus label after every typed turn --------- -# `_maybe_update_timeboxing_thread_constraints` runs at the end of the message -# route and redraws the root from `focus.get_thread_label(...)`. The label was -# minted at session start (pending, the suggested day) and nothing told it the -# day had changed, so the relabel above was overwritten with the old title -# milliseconds later (live session 1788429245.401169, 2026-09-03). - - -class _NoConstraints: - async def list_constraints(self, **_): - return [] +# -- The root the typed turn writes is the root that stays --------------------- +# The root used to be redrawn from the focus label by a constraints refresh +# after every turn, which overwrote a typed day change; that refresh no longer +# exists, and this pins that the relabel is the last write. @pytest.mark.asyncio -async def test_a_typed_day_change_survives_the_constraints_redraw(monkeypatch) -> None: +async def test_a_typed_day_change_is_what_the_root_shows(monkeypatch) -> None: from fateforger.slack_bot.focus import FocusManager friday = PlanningDay.lock_default( @@ -404,20 +397,12 @@ async def test_a_typed_day_change_survives_the_constraints_redraw(monkeypatch) - user_text="No, tomorrow please", focus=focus, ) - await handlers._maybe_update_timeboxing_thread_constraints( - client=client, - focus=focus, - thread_key="C1:1.0", - user_id="U1", - store=_NoConstraints(), - ) root_writes = [u for u in client.updates if u.get("ts") == "1.0"] - assert len(root_writes) == 2 - relabel, redraw = root_writes - assert redraw["text"] == relabel["text"] - assert redraw["text"].startswith(":large_blue_circle:") - assert "Thursday" not in redraw["text"] + assert len(root_writes) == 1 + relabel = root_writes[0] + assert relabel["text"].startswith(":large_blue_circle:") + assert "Thursday" not in relabel["text"] assert focus.get_thread_label("C1:1.0").state == "in_progress" diff --git a/tests/unit/timeboxing/test_sync_engine.py b/tests/unit/timeboxing/test_sync_engine.py deleted file mode 100644 index f1738115..00000000 --- a/tests/unit/timeboxing/test_sync_engine.py +++ /dev/null @@ -1,811 +0,0 @@ -"""Unit tests for ``fateforger.agents.timeboxing.sync_engine``. - -Covers: -- ``base32hex_id`` determinism and GCal safety -- ``is_owned_event`` prefix check -- ``gcal_response_to_tb_plan`` conversion -- ``plan_sync`` DeepDiff-based diffing (creates, updates, deletes, no-ops) -- ``execute_sync`` with mocked MCP workbench -- ``undo_sync`` compensating ops -- Foreign event protection (no mutations on non-fftb events) -""" - -from __future__ import annotations - -import re -from datetime import date, time, timedelta -from typing import Any -from unittest.mock import AsyncMock, MagicMock - -import pytest - -from fateforger.adapters.calendar.models import ( - GCalEvent, - GCalEventDateTime, - GCalEventsResponse, -) -from fateforger.agents.timeboxing.sync_engine import ( - FFTB_PREFIX, - SyncOp, - SyncOpType, - SyncTransaction, - base32hex_id, - execute_sync, - gcal_response_to_tb_plan, - gcal_response_to_tb_plan_with_identity, - is_owned_event, - plan_sync, - undo_sync, -) -from fateforger.agents.timeboxing.tb_models import ( - ET, - AfterPrev, - FixedStart, - FixedWindow, - TBEvent, - TBPlan, -) - -# ── Helpers ────────────────────────────────────────────────────────────── - - -def _make_gcal_response( - events: list[tuple[str, str, str, str | None]], -) -> GCalEventsResponse: - """Build a GCalEventsResponse from (id, summary, start_iso, end_iso) tuples. - - Args: - events: List of (id, summary, start_dateTime, end_dateTime) tuples. - The optional 4th element is colorId. - - Returns: - A GCalEventsResponse. - """ - gcal_events = [] - for ev in events: - eid, summary, start, end = ev[0], ev[1], ev[2], ev[3] - gcal_events.append( - GCalEvent( - id=eid, - summary=summary, - start=GCalEventDateTime(dateTime=start, timeZone="Europe/Amsterdam"), - end=GCalEventDateTime(dateTime=end, timeZone="Europe/Amsterdam"), - status="confirmed", - ) - ) - return GCalEventsResponse(events=gcal_events, totalCount=len(gcal_events)) - - -PLAN_DATE = date(2025, 6, 15) -TZ = "Europe/Amsterdam" - - -# ── base32hex_id ───────────────────────────────────────────────────────── - - -class TestBase32hexId: - """Test deterministic event ID generation.""" - - def test_deterministic(self) -> None: - a = base32hex_id("2025-06-15|Morning|08:00|0") - b = base32hex_id("2025-06-15|Morning|08:00|0") - assert a == b - - def test_different_seeds_differ(self) -> None: - a = base32hex_id("seed1") - b = base32hex_id("seed2") - assert a != b - - def test_starts_with_prefix(self) -> None: - eid = base32hex_id("test") - assert eid.startswith(FFTB_PREFIX) - - def test_gcal_safe_characters(self) -> None: - """GCal event IDs must only contain a-v and 0-9.""" - eid = base32hex_id("test-with-special-chars!@#$%") - allowed = set("abcdefghijklmnopqrstuv0123456789") - assert all(c in allowed for c in eid), f"Invalid chars in {eid}" - - def test_max_length_respected(self) -> None: - eid = base32hex_id("long-seed", max_len=20) - assert len(eid) <= 20 - - -# ── is_owned_event ─────────────────────────────────────────────────────── - - -class TestIsOwnedEvent: - """Test agent ownership detection.""" - - def test_owned(self) -> None: - assert is_owned_event("fftbabc123") is True - - def test_foreign(self) -> None: - assert is_owned_event("abc123def") is False - - def test_empty(self) -> None: - assert is_owned_event("") is False - - -# ── gcal_response_to_tb_plan ──────────────────────────────────────────── - - -class TestGcalResponseToTbPlan: - """Test GCal β†’ TBPlan conversion.""" - - def test_basic_conversion(self) -> None: - resp = _make_gcal_response( - [ - ( - "evt1", - "Standup", - "2025-06-15T09:00:00+02:00", - "2025-06-15T09:15:00+02:00", - ), - ( - "evt2", - "Lunch", - "2025-06-15T12:00:00+02:00", - "2025-06-15T13:00:00+02:00", - ), - ] - ) - plan, id_map = gcal_response_to_tb_plan(resp, plan_date=PLAN_DATE, tz_name=TZ) - - assert len(plan.events) == 2 - assert plan.events[0].n == "Standup" - assert plan.events[0].p.a == "fw" # all GCal events become fixed windows - assert plan.date == PLAN_DATE - - # Check event_id_map - assert "Standup|09:00:00" in id_map - assert id_map["Standup|09:00:00"] == "evt1" - - def test_skips_all_day_events(self) -> None: - resp = GCalEventsResponse( - events=[ - GCalEvent( - id="allday", - summary="Holiday", - start=GCalEventDateTime(date="2025-06-15"), - end=GCalEventDateTime(date="2025-06-16"), - ) - ], - totalCount=1, - ) - plan, id_map = gcal_response_to_tb_plan(resp, plan_date=PLAN_DATE, tz_name=TZ) - assert len(plan.events) == 0 - - def test_skips_cancelled(self) -> None: - resp = GCalEventsResponse( - events=[ - GCalEvent( - id="x", - summary="Cancelled", - start=GCalEventDateTime(dateTime="2025-06-15T10:00:00+02:00"), - end=GCalEventDateTime(dateTime="2025-06-15T11:00:00+02:00"), - status="cancelled", - ) - ], - totalCount=1, - ) - plan, _ = gcal_response_to_tb_plan(resp, plan_date=PLAN_DATE, tz_name=TZ) - assert len(plan.events) == 0 - - def test_skips_wrong_date(self) -> None: - resp = _make_gcal_response( - [ - ( - "evt1", - "Tomorrow", - "2025-06-16T09:00:00+02:00", - "2025-06-16T10:00:00+02:00", - ), - ] - ) - plan, _ = gcal_response_to_tb_plan(resp, plan_date=PLAN_DATE, tz_name=TZ) - assert len(plan.events) == 0 - - def test_sorts_by_start_time(self) -> None: - resp = _make_gcal_response( - [ - ( - "b", - "Later", - "2025-06-15T14:00:00+02:00", - "2025-06-15T15:00:00+02:00", - ), - ( - "a", - "Earlier", - "2025-06-15T09:00:00+02:00", - "2025-06-15T10:00:00+02:00", - ), - ] - ) - plan, _ = gcal_response_to_tb_plan(resp, plan_date=PLAN_DATE, tz_name=TZ) - assert plan.events[0].n == "Earlier" - assert plan.events[1].n == "Later" - - def test_identity_variant_returns_ordered_ids(self) -> None: - resp = _make_gcal_response( - [ - ( - "evt-b", - "Later", - "2025-06-15T14:00:00+02:00", - "2025-06-15T15:00:00+02:00", - ), - ( - "evt-a", - "Earlier", - "2025-06-15T09:00:00+02:00", - "2025-06-15T10:00:00+02:00", - ), - ] - ) - plan, _id_map, ids = gcal_response_to_tb_plan_with_identity( - resp, plan_date=PLAN_DATE, tz_name=TZ - ) - assert [event.n for event in plan.events] == ["Earlier", "Later"] - assert ids == ["evt-a", "evt-b"] - - -# ── plan_sync ──────────────────────────────────────────────────────────── - - -class TestPlanSync: - """Test DeepDiff-based sync planning.""" - - def test_identical_plans_no_ops(self) -> None: - """No changes β†’ no sync ops.""" - plan = TBPlan( - events=[ - TBEvent( - n="A", - d="", - t="DW", - p=FixedStart(st=time(9), dur=timedelta(hours=1)), - ) - ], - date=PLAN_DATE, - tz=TZ, - ) - ops = plan_sync(plan, plan, {}, calendar_id="primary") - assert len(ops) == 0 - - def test_new_events_create_ops(self) -> None: - """Events in desired but not remote β†’ creates.""" - remote = TBPlan(events=[], date=PLAN_DATE, tz=TZ) - desired = TBPlan( - events=[ - TBEvent( - n="New", - d="desc", - t="DW", - p=FixedStart(st=time(9), dur=timedelta(hours=1)), - ) - ], - date=PLAN_DATE, - tz=TZ, - ) - ops = plan_sync(remote, desired, {}) - assert len(ops) == 1 - assert ops[0].op_type == SyncOpType.CREATE - assert ops[0].gcal_event_id.startswith(FFTB_PREFIX) - assert ops[0].after_payload["summary"] == "New" - assert re.fullmatch( - r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", ops[0].after_payload["start"] - ) - assert re.fullmatch( - r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", ops[0].after_payload["end"] - ) - assert "+" not in ops[0].after_payload["start"] - assert "+" not in ops[0].after_payload["end"] - - def test_removed_owned_events_delete_ops(self) -> None: - """Owned events in remote but not desired β†’ deletes.""" - remote = TBPlan( - events=[ - TBEvent( - n="Old", - d="", - t="DW", - p=FixedStart(st=time(9), dur=timedelta(hours=1)), - ) - ], - date=PLAN_DATE, - tz=TZ, - ) - desired = TBPlan(events=[], date=PLAN_DATE, tz=TZ) - - # Map the event to an owned ID - event_id_map = {"Old|09:00:00": "fftbabc123"} - ops = plan_sync(remote, desired, event_id_map) - assert len(ops) == 1 - assert ops[0].op_type == SyncOpType.DELETE - assert ops[0].gcal_event_id == "fftbabc123" - - def test_foreign_events_not_deleted(self) -> None: - """Foreign events (non-fftb ID) should not be deleted.""" - remote = TBPlan( - events=[ - TBEvent( - n="Meeting", d="", t="M", p=FixedWindow(st=time(10), et=time(11)) - ) - ], - date=PLAN_DATE, - tz=TZ, - ) - desired = TBPlan(events=[], date=PLAN_DATE, tz=TZ) - - event_id_map = {"Meeting|10:00:00": "foreign_gcal_id"} - ops = plan_sync(remote, desired, event_id_map) - assert len(ops) == 0 # foreign event not deleted - - def test_changed_event_update_ops(self) -> None: - """Changed fields on owned events β†’ updates.""" - remote = TBPlan( - events=[ - TBEvent( - n="Work", - d="old desc", - t="DW", - p=FixedStart(st=time(9), dur=timedelta(hours=2)), - ) - ], - date=PLAN_DATE, - tz=TZ, - ) - desired = TBPlan( - events=[ - TBEvent( - n="Work", - d="new desc", - t="DW", - p=FixedStart(st=time(9), dur=timedelta(hours=2)), - ) - ], - date=PLAN_DATE, - tz=TZ, - ) - - event_id_map = {"Work|09:00:00": "fftbwork123"} - ops = plan_sync(remote, desired, event_id_map) - assert len(ops) == 1 - assert ops[0].op_type == SyncOpType.UPDATE - assert ops[0].after_payload["description"] == "new desc" - assert ops[0].before_payload is not None - assert ops[0].before_payload["description"] == "old desc" - assert "root['description']" in ops[0].diff_paths - assert re.fullmatch( - r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", ops[0].after_payload["start"] - ) - assert re.fullmatch( - r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", ops[0].after_payload["end"] - ) - assert "+" not in ops[0].after_payload["start"] - assert "+" not in ops[0].after_payload["end"] - - def test_creates_before_deletes_ordering(self) -> None: - """Creates should come before deletes in the ops list.""" - remote = TBPlan( - events=[ - TBEvent( - n="Old", - d="", - t="DW", - p=FixedStart(st=time(9), dur=timedelta(hours=1)), - ) - ], - date=PLAN_DATE, - tz=TZ, - ) - desired = TBPlan( - events=[ - TBEvent( - n="New", - d="", - t="DW", - p=FixedStart(st=time(10), dur=timedelta(hours=1)), - ) - ], - date=PLAN_DATE, - tz=TZ, - ) - - event_id_map = {"Old|09:00:00": "fftbold123"} - ops = plan_sync(remote, desired, event_id_map) - op_types = [op.op_type for op in ops] - # Creates should appear before deletes - if SyncOpType.CREATE in op_types and SyncOpType.DELETE in op_types: - assert op_types.index(SyncOpType.CREATE) < op_types.index(SyncOpType.DELETE) - - def test_remote_identity_enables_update_without_key_hint(self) -> None: - """When key-based map is missing, ordered remote IDs should still prevent duplicates.""" - remote = TBPlan( - events=[ - TBEvent( - n="Deep Work", - d="", - t="DW", - p=FixedWindow(st=time(9, 0), et=time(10, 0)), - ) - ], - date=PLAN_DATE, - tz=TZ, - ) - desired = TBPlan( - events=[ - TBEvent( - n="Deep Work", - d="", - t="DW", - p=FixedWindow(st=time(9, 15), et=time(10, 15)), - ) - ], - date=PLAN_DATE, - tz=TZ, - ) - ops = plan_sync( - remote, - desired, - {}, - remote_event_ids_by_index=["fftb-owned-1"], - ) - assert [op.op_type for op in ops] == [SyncOpType.UPDATE] - assert ops[0].gcal_event_id == "fftb-owned-1" - - def test_foreign_match_is_noop_not_create(self) -> None: - """Foreign events should never mutate or duplicate when reconciled.""" - remote = TBPlan( - events=[ - TBEvent( - n="Lunch", - d="", - t="M", - p=FixedWindow(st=time(12, 0), et=time(13, 0)), - ) - ], - date=PLAN_DATE, - tz=TZ, - ) - desired = TBPlan( - events=[ - TBEvent( - n="Lunch", - d="changed but foreign", - t="M", - p=FixedWindow(st=time(12, 5), et=time(13, 5)), - ) - ], - date=PLAN_DATE, - tz=TZ, - ) - ops = plan_sync( - remote, - desired, - {}, - remote_event_ids_by_index=["foreign-event-id-1"], - ) - assert ops == [] - - def test_foreign_overlap_summary_mismatch_is_noop_not_create(self) -> None: - """Foreign overlaps with renamed summaries should still avoid duplicate creates.""" - remote = TBPlan( - events=[ - TBEvent( - n="Lunch", - d="", - t="M", - p=FixedWindow(st=time(13, 0), et=time(14, 0)), - ) - ], - date=PLAN_DATE, - tz=TZ, - ) - desired = TBPlan( - events=[ - TBEvent( - n="Lunch Break", - d="planner wording changed", - t="M", - p=FixedWindow(st=time(13, 0), et=time(14, 0)), - ) - ], - date=PLAN_DATE, - tz=TZ, - ) - ops = plan_sync( - remote, - desired, - {}, - remote_event_ids_by_index=["foreign-event-id-2"], - ) - assert ops == [] - - -# ── execute_sync ───────────────────────────────────────────────────────── - - -class TestExecuteSync: - """Test sync execution with mocked MCP workbench.""" - - @pytest.fixture() - def mock_workbench(self) -> AsyncMock: - wb = AsyncMock() - result = MagicMock() - result.is_error = False - result.result = [MagicMock(text='{"ok": true}')] - wb.call_tool.return_value = result - return wb - - @pytest.mark.asyncio - async def test_successful_execution(self, mock_workbench: AsyncMock) -> None: - ops = [ - SyncOp( - op_type=SyncOpType.CREATE, - gcal_event_id="fftbtest123", - after_payload={"calendarId": "primary", "summary": "Test"}, - ), - ] - tx = await execute_sync(ops, mock_workbench) - assert tx.status == "committed" - assert len(tx.results) == 1 - assert tx.results[0]["ok"] is True - mock_workbench.call_tool.assert_called_once_with( - "create-event", - arguments={"calendarId": "primary", "summary": "Test"}, - ) - - @pytest.mark.asyncio - async def test_failed_op_marks_partial(self, mock_workbench: AsyncMock) -> None: - error_result = MagicMock() - error_result.is_error = True - error_result.result = [MagicMock(text="Event not found")] - mock_workbench.call_tool.return_value = error_result - - ops = [ - SyncOp( - op_type=SyncOpType.DELETE, - gcal_event_id="fftbgone", - after_payload={"calendarId": "primary", "eventId": "fftbgone"}, - ) - ] - tx = await execute_sync(ops, mock_workbench) - assert tx.status == "partial" - assert tx.results[0]["ok"] is False - - @pytest.mark.asyncio - async def test_exception_marks_partial(self, mock_workbench: AsyncMock) -> None: - mock_workbench.call_tool.side_effect = RuntimeError("Connection failed") - - ops = [ - SyncOp( - op_type=SyncOpType.CREATE, - gcal_event_id="fftbtest", - after_payload={"calendarId": "primary", "summary": "X"}, - ) - ] - tx = await execute_sync(ops, mock_workbench) - assert tx.status == "partial" - assert "error" in tx.results[0] - - @pytest.mark.asyncio - async def test_multiple_ops_executed_sequentially( - self, mock_workbench: AsyncMock - ) -> None: - ops = [ - SyncOp( - op_type=SyncOpType.CREATE, - gcal_event_id="a", - after_payload={"summary": "A"}, - ), - SyncOp( - op_type=SyncOpType.CREATE, - gcal_event_id="b", - after_payload={"summary": "B"}, - ), - ] - tx = await execute_sync(ops, mock_workbench) - assert tx.status == "committed" - assert mock_workbench.call_tool.call_count == 2 - - @pytest.mark.asyncio - async def test_halt_on_error_stops_remaining_ops(self, mock_workbench: AsyncMock) -> None: - error_result = MagicMock() - error_result.is_error = True - error_result.result = [MagicMock(text="bad request")] - mock_workbench.call_tool.return_value = error_result - - ops = [ - SyncOp( - op_type=SyncOpType.UPDATE, - gcal_event_id="fftb-first", - after_payload={"summary": "First"}, - ), - SyncOp( - op_type=SyncOpType.DELETE, - gcal_event_id="fftb-second", - after_payload={"eventId": "fftb-second"}, - ), - ] - tx = await execute_sync(ops, mock_workbench, halt_on_error=True) - assert tx.status == "partial_halted" - assert len(tx.results) == 1 - assert mock_workbench.call_tool.call_count == 1 - - -# ── undo_sync ──────────────────────────────────────────────────────────── - - -class TestUndoSync: - """Test compensating undo operations.""" - - @pytest.fixture() - def mock_workbench(self) -> AsyncMock: - wb = AsyncMock() - result = MagicMock() - result.is_error = False - result.result = [MagicMock(text='{"ok": true}')] - wb.call_tool.return_value = result - return wb - - @pytest.mark.asyncio - async def test_undo_create_becomes_delete(self, mock_workbench: AsyncMock) -> None: - tx = SyncTransaction( - ops=[ - SyncOp( - op_type=SyncOpType.CREATE, - gcal_event_id="fftbcreated", - after_payload={ - "calendarId": "primary", - "eventId": "fftbcreated", - "summary": "New", - }, - ), - ], - results=[{"ok": True, "event_id": "fftbcreated"}], - ) - undo_tx = await undo_sync(tx, mock_workbench) - assert undo_tx.status == "undone" - # Should have called delete-event - call_args = mock_workbench.call_tool.call_args - assert call_args[0][0] == "delete-event" - - @pytest.mark.asyncio - async def test_undo_update_restores_before(self, mock_workbench: AsyncMock) -> None: - before = {"calendarId": "primary", "eventId": "fftbx", "summary": "Old"} - after = {"calendarId": "primary", "eventId": "fftbx", "summary": "New"} - tx = SyncTransaction( - ops=[ - SyncOp( - op_type=SyncOpType.UPDATE, - gcal_event_id="fftbx", - after_payload=after, - before_payload=before, - ), - ], - results=[{"ok": True, "event_id": "fftbx"}], - ) - undo_tx = await undo_sync(tx, mock_workbench) - assert undo_tx.status == "undone" - call_args = mock_workbench.call_tool.call_args - assert call_args[0][0] == "update-event" - # Should restore with before payload - assert call_args[1]["arguments"]["summary"] == "Old" - - @pytest.mark.asyncio - async def test_undo_delete_recreates(self, mock_workbench: AsyncMock) -> None: - before = {"calendarId": "primary", "eventId": "fftbdel", "summary": "Deleted"} - tx = SyncTransaction( - ops=[ - SyncOp( - op_type=SyncOpType.DELETE, - gcal_event_id="fftbdel", - after_payload={"calendarId": "primary", "eventId": "fftbdel"}, - before_payload=before, - ), - ], - results=[{"ok": True, "event_id": "fftbdel"}], - ) - undo_tx = await undo_sync(tx, mock_workbench) - assert undo_tx.status == "undone" - call_args = mock_workbench.call_tool.call_args - assert call_args[0][0] == "create-event" - assert call_args[1]["arguments"]["summary"] == "Deleted" - - @pytest.mark.asyncio - async def test_undo_reverses_order(self, mock_workbench: AsyncMock) -> None: - """Undo should process ops in reverse order.""" - tx = SyncTransaction( - ops=[ - SyncOp( - op_type=SyncOpType.CREATE, - gcal_event_id="fftba", - after_payload={"calendarId": "primary", "eventId": "fftba"}, - ), - SyncOp( - op_type=SyncOpType.CREATE, - gcal_event_id="fftbb", - after_payload={"calendarId": "primary", "eventId": "fftbb"}, - ), - ], - results=[ - {"ok": True, "event_id": "fftba"}, - {"ok": True, "event_id": "fftbb"}, - ], - ) - undo_tx = await undo_sync(tx, mock_workbench) - # First undo call should be for fftbb (last created) - calls = mock_workbench.call_tool.call_args_list - assert calls[0][1]["arguments"]["eventId"] == "fftbb" - assert calls[1][1]["arguments"]["eventId"] == "fftba" - - @pytest.mark.asyncio - async def test_undo_skips_forward_ops_that_failed( - self, mock_workbench: AsyncMock - ) -> None: - """Undo should compensate only forward ops that completed successfully.""" - tx = SyncTransaction( - ops=[ - SyncOp( - op_type=SyncOpType.CREATE, - gcal_event_id="fftb-failed", - after_payload={"calendarId": "primary", "eventId": "fftb-failed"}, - ), - SyncOp( - op_type=SyncOpType.CREATE, - gcal_event_id="fftb-ok", - after_payload={"calendarId": "primary", "eventId": "fftb-ok"}, - ), - ], - results=[ - {"ok": False, "event_id": "fftb-failed"}, - {"ok": True, "event_id": "fftb-ok"}, - ], - status="partial", - ) - - undo_tx = await undo_sync(tx, mock_workbench) - assert undo_tx.status == "undone" - calls = mock_workbench.call_tool.call_args_list - assert len(calls) == 1 - assert calls[0][0][0] == "delete-event" - assert calls[0][1]["arguments"]["eventId"] == "fftb-ok" - - @pytest.mark.asyncio - async def test_undo_raises_when_transaction_results_missing( - self, mock_workbench: AsyncMock - ) -> None: - """Undo should fail loudly when deterministic execution results are absent.""" - tx = SyncTransaction( - ops=[ - SyncOp( - op_type=SyncOpType.CREATE, - gcal_event_id="fftb-created", - after_payload={"calendarId": "primary", "eventId": "fftb-created"}, - ) - ], - status="committed", - ) - - with pytest.raises(ValueError, match="complete per-op execution results"): - await undo_sync(tx, mock_workbench) - - -# ── SyncTransaction ───────────────────────────────────────────────────── - - -class TestSyncTransaction: - """Test SyncTransaction dataclass.""" - - def test_default_status(self) -> None: - tx = SyncTransaction() - assert tx.status == "pending" - assert tx.ops == [] - assert tx.results == [] - - def test_timestamp_set(self) -> None: - tx = SyncTransaction() - assert tx.timestamp # should have a default timestamp diff --git a/tests/unit/timeboxing/test_sync_reconciliation_summary.py b/tests/unit/timeboxing/test_sync_reconciliation_summary.py deleted file mode 100644 index ebac4dc8..00000000 --- a/tests/unit/timeboxing/test_sync_reconciliation_summary.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Unit tests for shared reconciliation summary contract.""" - -from __future__ import annotations - -from datetime import date, time - -from fateforger.agents.timeboxing.tb_models import ET, FixedWindow, TBEvent, TBPlan -from fateforger.sync_core.reconciliation_summary import summarize_reconciliation - - -def _plan(events: list[TBEvent]) -> TBPlan: - return TBPlan(events=events, date=date(2026, 3, 11), tz="Europe/Amsterdam") - - -def test_summarize_reconciliation_counts() -> None: - remote = _plan( - [ - TBEvent( - n="Focus", - d="", - t=ET.DW, - p=FixedWindow(st=time(9, 0), et=time(10, 0)), - ), - TBEvent( - n="Lunch", - d="", - t=ET.BU, - p=FixedWindow(st=time(12, 0), et=time(13, 0)), - ), - ] - ) - desired = _plan( - [ - TBEvent( - n="Focus", - d="", - t=ET.DW, - p=FixedWindow(st=time(9, 30), et=time(10, 30)), - ), - TBEvent( - n="Lunch", - d="", - t=ET.BU, - p=FixedWindow(st=time(12, 0), et=time(13, 0)), - ), - TBEvent( - n="Deep Work", - d="", - t=ET.DW, - p=FixedWindow(st=time(14, 0), et=time(15, 0)), - ), - ] - ) - event_id_map = { - "Focus|09:00:00": "fftb_focus_1", - "Lunch|12:00:00": "external_lunch_1", - } - remote_event_ids_by_index = ["fftb_focus_1", "external_lunch_1"] - - summary = summarize_reconciliation( - remote=remote, - desired=desired, - event_id_map=event_id_map, - remote_event_ids_by_index=remote_event_ids_by_index, - ) - - assert summary.remote_fetched == 2 - assert summary.matched == 2 - assert summary.create == 1 - assert summary.update == 1 - assert summary.noop == 1 - assert summary.delete == 0 - - -def test_planned_mutations_is_create_update_delete_sum() -> None: - remote = _plan([]) - desired = _plan( - [ - TBEvent( - n="One", - d="", - t=ET.DW, - p=FixedWindow(st=time(11, 0), et=time(12, 0)), - ) - ] - ) - - summary = summarize_reconciliation( - remote=remote, - desired=desired, - event_id_map={}, - remote_event_ids_by_index=[], - ) - - assert summary.create == 1 - assert summary.update == 0 - assert summary.delete == 0 - assert summary.planned_mutations == 1 - - -def test_summarize_reconciliation_reports_owned_delete() -> None: - remote = _plan( - [ - TBEvent( - n="Owned", - d="", - t=ET.DW, - p=FixedWindow(st=time(8, 0), et=time(9, 0)), - ) - ] - ) - desired = _plan([]) - - summary = summarize_reconciliation( - remote=remote, - desired=desired, - event_id_map={"Owned|08:00:00": "fftb_owned_1"}, - remote_event_ids_by_index=["fftb_owned_1"], - ) - - assert summary.remote_fetched == 1 - assert summary.create == 0 - assert summary.update == 0 - assert summary.noop == 0 - assert summary.delete == 1 - assert summary.planned_mutations == 1 - - -def test_summarize_reconciliation_foreign_overlap_is_noop() -> None: - remote = _plan( - [ - TBEvent( - n="Lunch", - d="", - t=ET.BU, - p=FixedWindow(st=time(12, 0), et=time(13, 0)), - ) - ] - ) - desired = _plan( - [ - TBEvent( - n="Lunch", - d="", - t=ET.BU, - p=FixedWindow(st=time(12, 0), et=time(13, 0)), - ) - ] - ) - - summary = summarize_reconciliation( - remote=remote, - desired=desired, - event_id_map={"Lunch|12:00:00": "foreign_lunch_1"}, - remote_event_ids_by_index=["foreign_lunch_1"], - ) - - assert summary.remote_fetched == 1 - assert summary.matched == 1 - assert summary.create == 0 - assert summary.update == 0 - assert summary.noop == 1 - assert summary.delete == 0 - assert summary.planned_mutations == 0 diff --git a/tests/unit/timeboxing/test_tb_models.py b/tests/unit/timeboxing/test_tb_models.py deleted file mode 100644 index a5b11fc9..00000000 --- a/tests/unit/timeboxing/test_tb_models.py +++ /dev/null @@ -1,411 +0,0 @@ -"""Unit tests for ``fateforger.agents.timeboxing.tb_models``. - -Covers: -- Discriminated union (de)serialization for all 4 Timing variants -- TBEvent validation (BG must have fixed timing) -- TBPlan validation (chain must have an anchor) -- Time resolution: forward pass (ap, fs, fw), backward pass (bn), overlap detection -- ET_COLOR_MAP helpers -- JSON schema round-trip (confirms LLM can produce valid payloads) -""" - -from __future__ import annotations - -from datetime import date, time, timedelta - -import pytest - -from fateforger.agents.timeboxing.tb_models import ( - ET, - ET_COLOR_MAP, - AfterPrev, - BeforeNext, - FixedStart, - FixedWindow, - TBEvent, - TBPlan, - gcal_color_to_et, -) - -# ── Timing variant construction ────────────────────────────────────────── - - -class TestTimingVariants: - """Test constructing each Timing variant from raw values and dicts.""" - - def test_after_prev_from_iso(self) -> None: - ap = AfterPrev(dur="PT30M") - assert ap.dur == timedelta(minutes=30) - assert ap.a == "ap" - - def test_before_next_from_iso(self) -> None: - bn = BeforeNext(dur="PT1H") - assert bn.dur == timedelta(hours=1) - assert bn.a == "bn" - - def test_fixed_start_from_str(self) -> None: - fs = FixedStart(st="09:00", dur="PT45M") - assert fs.st == time(9, 0) - assert fs.dur == timedelta(minutes=45) - - def test_fixed_window_from_str(self) -> None: - fw = FixedWindow(st="10:00", et="11:30") - assert fw.st == time(10, 0) - assert fw.et == time(11, 30) - - def test_fixed_start_from_native(self) -> None: - fs = FixedStart(st=time(8, 30), dur=timedelta(hours=1)) - assert fs.st == time(8, 30) - - def test_extra_fields_forbidden(self) -> None: - with pytest.raises(Exception): - AfterPrev(dur="PT30M", extra="nope") - - -# ── TBEvent validation ─────────────────────────────────────────────────── - - -class TestTBEvent: - """Test TBEvent creation and validators.""" - - def test_basic_event(self) -> None: - ev = TBEvent( - n="Standup", d="Daily", t="M", p={"a": "fs", "st": "09:00", "dur": "PT15M"} - ) - assert ev.t == ET.M - assert ev.n == "Standup" - - def test_bg_requires_fixed_timing(self) -> None: - """BG events with after_previous should be rejected.""" - with pytest.raises(ValueError, match="Background events"): - TBEvent(n="BGTask", d="", t="BG", p={"a": "ap", "dur": "PT30M"}) - - def test_bg_with_fixed_window_ok(self) -> None: - ev = TBEvent( - n="BGTask", d="", t="BG", p={"a": "fw", "st": "09:00", "et": "17:00"} - ) - assert ev.t == ET.BG - - def test_bg_with_fixed_start_ok(self) -> None: - ev = TBEvent( - n="BGTask", d="", t="BG", p={"a": "fs", "st": "09:00", "dur": "PT8H"} - ) - assert ev.t == ET.BG - - def test_extra_fields_forbidden(self) -> None: - with pytest.raises(Exception): - TBEvent(n="X", d="", t="M", p={"a": "ap", "dur": "PT30M"}, extra="bad") - - -# ── TBPlan validation ──────────────────────────────────────────────────── - - -class TestTBPlanValidation: - """Test TBPlan model validators.""" - - def test_empty_plan_ok(self) -> None: - plan = TBPlan(events=[]) - assert plan.events == [] - - def test_chain_needs_anchor(self) -> None: - """A chain of only after_previous events is invalid (no time anchor).""" - with pytest.raises(ValueError, match="anchor"): - TBPlan( - events=[ - TBEvent(n="A", d="", t="DW", p={"a": "ap", "dur": "PT1H"}), - ] - ) - - def test_chain_with_fixed_start_anchor(self) -> None: - plan = TBPlan( - events=[ - TBEvent( - n="Start", - d="", - t="PR", - p={"a": "fs", "st": "08:00", "dur": "PT30M"}, - ), - TBEvent(n="Work", d="", t="DW", p={"a": "ap", "dur": "PT2H"}), - ] - ) - assert len(plan.events) == 2 - - def test_bg_only_plan_no_anchor_needed(self) -> None: - """BG events are excluded from the chain anchor check.""" - plan = TBPlan( - events=[ - TBEvent( - n="Music", d="", t="BG", p={"a": "fw", "st": "08:00", "et": "17:00"} - ), - ] - ) - assert len(plan.events) == 1 - - -# ── Time resolution ────────────────────────────────────────────────────── - - -class TestResolveTime: - """Test ``TBPlan.resolve_times()`` β€” the core scheduling logic.""" - - @pytest.fixture() - def day(self) -> date: - return date(2025, 1, 15) - - def test_single_fixed_start(self, day: date) -> None: - plan = TBPlan( - events=[ - TBEvent( - n="Morning", - d="", - t="PR", - p={"a": "fs", "st": "08:00", "dur": "PT1H"}, - ) - ], - date=day, - ) - resolved = plan.resolve_times() - assert len(resolved) == 1 - assert resolved[0]["start_time"] == time(8, 0) - assert resolved[0]["end_time"] == time(9, 0) - - def test_after_prev_chain(self, day: date) -> None: - plan = TBPlan( - events=[ - TBEvent( - n="A", d="", t="PR", p={"a": "fs", "st": "07:00", "dur": "PT1H"} - ), - TBEvent(n="B", d="", t="DW", p={"a": "ap", "dur": "PT30M"}), - TBEvent(n="C", d="", t="SW", p={"a": "ap", "dur": "PT45M"}), - ], - date=day, - ) - resolved = plan.resolve_times() - assert resolved[0]["end_time"] == time(8, 0) - assert resolved[1]["start_time"] == time(8, 0) - assert resolved[1]["end_time"] == time(8, 30) - assert resolved[2]["start_time"] == time(8, 30) - assert resolved[2]["end_time"] == time(9, 15) - - def test_fixed_window(self, day: date) -> None: - plan = TBPlan( - events=[ - TBEvent( - n="Meeting", - d="", - t="M", - p={"a": "fw", "st": "14:00", "et": "15:30"}, - ) - ], - date=day, - ) - resolved = plan.resolve_times() - assert resolved[0]["start_time"] == time(14, 0) - assert resolved[0]["end_time"] == time(15, 30) - assert resolved[0]["duration"] == timedelta(hours=1, minutes=30) - - def test_before_next(self, day: date) -> None: - """before_next event should end exactly when the next event starts.""" - plan = TBPlan( - events=[ - TBEvent(n="Prep", d="", t="SW", p={"a": "bn", "dur": "PT30M"}), - TBEvent( - n="Meeting", - d="", - t="M", - p={"a": "fs", "st": "10:00", "dur": "PT1H"}, - ), - ], - date=day, - ) - resolved = plan.resolve_times() - assert resolved[0]["start_time"] == time(9, 30) - assert resolved[0]["end_time"] == time(10, 0) - assert resolved[1]["start_time"] == time(10, 0) - - def test_before_next_no_successor_raises(self, day: date) -> None: - """bn as the last event has no successor β†’ resolve_times error.""" - # Bypass chain_must_be_anchored validator (bn alone has no anchor) - plan = TBPlan.__new__(TBPlan) - object.__setattr__( - plan, - "events", - [ - TBEvent(n="Dangling", d="", t="SW", p={"a": "bn", "dur": "PT30M"}), - ], - ) - object.__setattr__(plan, "date", day) - object.__setattr__(plan, "tz", "Europe/Amsterdam") - with pytest.raises(ValueError, match="no following"): - plan.resolve_times() - - def test_after_prev_no_predecessor_raises(self, day: date) -> None: - """after_previous as first event has no predecessor β†’ error.""" - plan = TBPlan.__new__(TBPlan) - # Bypass the chain_must_be_anchored validator for this edge case - object.__setattr__( - plan, - "events", - [ - TBEvent(n="Orphan", d="", t="DW", p={"a": "ap", "dur": "PT1H"}), - ], - ) - object.__setattr__(plan, "date", day) - object.__setattr__(plan, "tz", "Europe/Amsterdam") - with pytest.raises(ValueError, match="no preceding"): - plan.resolve_times() - - def test_overlap_detected(self, day: date) -> None: - """Two fixed events that overlap should raise.""" - plan = TBPlan( - events=[ - TBEvent( - n="A", d="", t="M", p={"a": "fs", "st": "10:00", "dur": "PT1H"} - ), - TBEvent( - n="B", d="", t="M", p={"a": "fs", "st": "10:30", "dur": "PT1H"} - ), - ], - date=day, - ) - with pytest.raises(ValueError, match="Overlap"): - plan.resolve_times() - - def test_bg_events_excluded_from_overlap(self, day: date) -> None: - """BG events should not trigger overlap errors with chain events.""" - plan = TBPlan( - events=[ - TBEvent( - n="Music", d="", t="BG", p={"a": "fw", "st": "08:00", "et": "17:00"} - ), - TBEvent( - n="Work", d="", t="DW", p={"a": "fs", "st": "09:00", "dur": "PT2H"} - ), - ], - date=day, - ) - resolved = plan.resolve_times() - assert len(resolved) == 2 - - def test_mixed_timing_plan(self, day: date) -> None: - """Complex plan with multiple timing types resolves correctly.""" - plan = TBPlan( - events=[ - TBEvent( - n="Morning routine", - d="", - t="H", - p={"a": "fs", "st": "07:00", "dur": "PT1H"}, - ), - TBEvent(n="Commute", d="", t="C", p={"a": "ap", "dur": "PT30M"}), - TBEvent( - n="Prep for standup", d="", t="SW", p={"a": "bn", "dur": "PT15M"} - ), - TBEvent( - n="Standup", - d="", - t="M", - p={"a": "fs", "st": "09:00", "dur": "PT15M"}, - ), - TBEvent(n="Deep work", d="", t="DW", p={"a": "ap", "dur": "PT3H"}), - ], - date=day, - ) - resolved = plan.resolve_times() - assert len(resolved) == 5 - # Morning routine: 07:00-08:00 - assert resolved[0]["start_time"] == time(7, 0) - assert resolved[0]["end_time"] == time(8, 0) - # Commute: 08:00-08:30 - assert resolved[1]["start_time"] == time(8, 0) - assert resolved[1]["end_time"] == time(8, 30) - # Prep: 08:45-09:00 (before_next from standup at 09:00) - assert resolved[2]["start_time"] == time(8, 45) - assert resolved[2]["end_time"] == time(9, 0) - # Standup: 09:00-09:15 - assert resolved[3]["start_time"] == time(9, 0) - assert resolved[3]["end_time"] == time(9, 15) - # Deep work: 09:15-12:15 - assert resolved[4]["start_time"] == time(9, 15) - assert resolved[4]["end_time"] == time(12, 15) - - def test_resolve_preserves_index(self, day: date) -> None: - plan = TBPlan( - events=[ - TBEvent( - n="A", d="", t="PR", p={"a": "fs", "st": "08:00", "dur": "PT1H"} - ), - TBEvent(n="B", d="", t="DW", p={"a": "ap", "dur": "PT2H"}), - ], - date=day, - ) - resolved = plan.resolve_times() - assert resolved[0]["index"] == 0 - assert resolved[1]["index"] == 1 - - -# ── ET_COLOR_MAP helpers ───────────────────────────────────────────────── - - -class TestColorMapping: - """Test ET ↔ GCal colorId mapping.""" - - def test_all_et_values_have_color(self) -> None: - for et in ET: - assert et.value in ET_COLOR_MAP, f"Missing color mapping for {et}" - - def test_gcal_color_to_et_known(self) -> None: - assert gcal_color_to_et("9") == ET.DW - assert gcal_color_to_et("6") == ET.M - - def test_gcal_color_to_et_unknown_defaults(self) -> None: - assert gcal_color_to_et("99") == ET.M - assert gcal_color_to_et(None) == ET.M - - -# ── JSON schema round-trip ──────────────────────────────────────────────── - - -class TestJsonRoundTrip: - """Test that models serialize/deserialize cleanly (LLM schema contract).""" - - def test_tb_event_round_trip(self) -> None: - ev = TBEvent( - n="Test", d="desc", t="DW", p={"a": "fs", "st": "09:00", "dur": "PT1H"} - ) - data = ev.model_dump(mode="json") - restored = TBEvent.model_validate(data) - assert restored.n == ev.n - assert restored.t == ev.t - assert restored.p.a == "fs" - - def test_tb_plan_round_trip(self) -> None: - plan = TBPlan( - events=[ - TBEvent( - n="A", d="", t="PR", p={"a": "fs", "st": "08:00", "dur": "PT30M"} - ), - TBEvent(n="B", d="", t="DW", p={"a": "ap", "dur": "PT2H"}), - ], - date=date(2025, 3, 1), - tz="Europe/Amsterdam", - ) - data = plan.model_dump(mode="json") - restored = TBPlan.model_validate(data) - assert len(restored.events) == 2 - assert restored.date == date(2025, 3, 1) - assert restored.events[1].p.a == "ap" - - def test_discriminator_in_json_schema(self) -> None: - """The JSON schema must expose the discriminator for strict tool calling.""" - schema = TBEvent.model_json_schema() - # The schema should reference all timing variants - defs = schema.get("$defs", {}) - timing_names = {d for d in defs} - assert "AfterPrev" in timing_names - assert "FixedWindow" in timing_names - - def test_tb_plan_json_schema_exists(self) -> None: - schema = TBPlan.model_json_schema() - assert "properties" in schema - assert "events" in schema["properties"] diff --git a/tests/unit/timeboxing/test_tb_ops.py b/tests/unit/timeboxing/test_tb_ops.py deleted file mode 100644 index 5a2835c0..00000000 --- a/tests/unit/timeboxing/test_tb_ops.py +++ /dev/null @@ -1,374 +0,0 @@ -"""Unit tests for ``fateforger.agents.timeboxing.tb_ops``. - -Covers: -- All 5 operation types: AddEvents, RemoveEvent, UpdateEvent, MoveEvent, ReplaceAll -- Index boundary checks (out-of-range raises IndexError) -- Discriminated union (de)serialization via ``TBOp`` / ``TBPatch`` -- ``apply_tb_ops`` correctly produces a new validated TBPlan -- Multi-op patches (sequential application) -""" - -from __future__ import annotations - -from datetime import date - -import pytest - -from fateforger.agents.timeboxing.tb_models import ET, TBEvent, TBPlan -from fateforger.agents.timeboxing.tb_ops import ( - AddEvents, - MoveEvent, - RemoveEvent, - ReplaceAll, - TBPatch, - UpdateEvent, - apply_tb_ops, -) - -# ── Helpers ────────────────────────────────────────────────────────────── - - -def _ev(name: str, dur: str = "PT1H", st: str = "08:00", t: str = "DW") -> TBEvent: - """Quick event factory β€” all events use fixed_start for simplicity.""" - return TBEvent(n=name, d="", t=t, p={"a": "fs", "st": st, "dur": dur}) - - -def _base_plan() -> TBPlan: - """A 3-event plan used as a baseline for most tests.""" - return TBPlan( - events=[ - _ev("Morning routine", st="07:00", dur="PT1H", t="H"), - _ev("Deep work", st="08:00", dur="PT2H", t="DW"), - _ev("Lunch", st="12:00", dur="PT1H", t="R"), - ], - date=date(2025, 1, 15), - tz="Europe/Amsterdam", - ) - - -# ── ReplaceAll ─────────────────────────────────────────────────────────── - - -class TestReplaceAll: - """ReplaceAll should set the entire event list.""" - - def test_replace_all(self) -> None: - plan = _base_plan() - new_events = [_ev("Only Event", st="09:00", dur="PT1H")] - patch = TBPatch(ops=[ReplaceAll(events=new_events)]) - result = apply_tb_ops(plan, patch) - assert len(result.events) == 1 - assert result.events[0].n == "Only Event" - - def test_replace_all_preserves_date(self) -> None: - plan = _base_plan() - patch = TBPatch(ops=[ReplaceAll(events=[_ev("A", st="09:00")])]) - result = apply_tb_ops(plan, patch) - assert result.date == date(2025, 1, 15) - assert result.tz == "Europe/Amsterdam" - - -# ── AddEvents ──────────────────────────────────────────────────────────── - - -class TestAddEvents: - """AddEvents appends or inserts events.""" - - def test_append(self) -> None: - plan = _base_plan() - patch = TBPatch(ops=[AddEvents(events=[_ev("Walk", st="15:00", t="H")])]) - result = apply_tb_ops(plan, patch) - assert len(result.events) == 4 - assert result.events[3].n == "Walk" - - def test_insert_after_index(self) -> None: - plan = _base_plan() - patch = TBPatch( - ops=[AddEvents(events=[_ev("Snack", st="10:00", t="R")], after=0)] - ) - result = apply_tb_ops(plan, patch) - assert len(result.events) == 4 - assert result.events[1].n == "Snack" - assert result.events[0].n == "Morning routine" - assert result.events[2].n == "Deep work" - - def test_insert_multiple_events(self) -> None: - plan = _base_plan() - new = [ - _ev("Break1", st="10:00", t="R"), - _ev("Break2", st="11:00", t="R"), - ] - patch = TBPatch(ops=[AddEvents(events=new, after=1)]) - result = apply_tb_ops(plan, patch) - assert len(result.events) == 5 - assert result.events[2].n == "Break1" - assert result.events[3].n == "Break2" - - -# ── RemoveEvent ────────────────────────────────────────────────────────── - - -class TestRemoveEvent: - """RemoveEvent drops an event by index.""" - - def test_remove_middle(self) -> None: - plan = _base_plan() - patch = TBPatch(ops=[RemoveEvent(i=1)]) - result = apply_tb_ops(plan, patch) - assert len(result.events) == 2 - assert result.events[0].n == "Morning routine" - assert result.events[1].n == "Lunch" - - def test_remove_first(self) -> None: - plan = _base_plan() - patch = TBPatch(ops=[RemoveEvent(i=0)]) - result = apply_tb_ops(plan, patch) - assert len(result.events) == 2 - assert result.events[0].n == "Deep work" - - def test_remove_last(self) -> None: - plan = _base_plan() - patch = TBPatch(ops=[RemoveEvent(i=2)]) - result = apply_tb_ops(plan, patch) - assert len(result.events) == 2 - - def test_remove_out_of_range(self) -> None: - plan = _base_plan() - patch = TBPatch(ops=[RemoveEvent(i=10)]) - with pytest.raises(IndexError, match="remove"): - apply_tb_ops(plan, patch) - - def test_remove_negative_index(self) -> None: - plan = _base_plan() - patch = TBPatch(ops=[RemoveEvent(i=-1)]) - with pytest.raises(IndexError, match="remove"): - apply_tb_ops(plan, patch) - - -# ── UpdateEvent ────────────────────────────────────────────────────────── - - -class TestUpdateEvent: - """UpdateEvent merges partial changes onto an existing event.""" - - def test_update_name(self) -> None: - plan = _base_plan() - patch = TBPatch(ops=[UpdateEvent(i=0, n="New Name")]) - result = apply_tb_ops(plan, patch) - assert result.events[0].n == "New Name" - assert result.events[0].t == ET.H # unchanged - - def test_update_type(self) -> None: - plan = _base_plan() - patch = TBPatch(ops=[UpdateEvent(i=1, t=ET.SW)]) - result = apply_tb_ops(plan, patch) - assert result.events[1].t == ET.SW - assert result.events[1].n == "Deep work" # unchanged - - def test_update_description(self) -> None: - plan = _base_plan() - patch = TBPatch(ops=[UpdateEvent(i=0, d="Updated desc")]) - result = apply_tb_ops(plan, patch) - assert result.events[0].d == "Updated desc" - - def test_update_timing(self) -> None: - plan = _base_plan() - from fateforger.agents.timeboxing.tb_models import FixedStart - - new_timing = FixedStart(st="09:30", dur="PT45M") - patch = TBPatch(ops=[UpdateEvent(i=1, p=new_timing)]) - result = apply_tb_ops(plan, patch) - assert result.events[1].p.a == "fs" - assert result.events[1].p.st.hour == 9 - assert result.events[1].p.st.minute == 30 - - def test_update_out_of_range(self) -> None: - plan = _base_plan() - patch = TBPatch(ops=[UpdateEvent(i=99, n="boom")]) - with pytest.raises(IndexError, match="update"): - apply_tb_ops(plan, patch) - - def test_update_preserves_unset_fields(self) -> None: - plan = _base_plan() - original_timing = plan.events[0].p - patch = TBPatch(ops=[UpdateEvent(i=0, n="Renamed")]) - result = apply_tb_ops(plan, patch) - assert result.events[0].p == original_timing - - -# ── MoveEvent ──────────────────────────────────────────────────────────── - - -class TestMoveEvent: - """MoveEvent reorders events within the list.""" - - def test_move_forward(self) -> None: - plan = _base_plan() - # Move first event to position 2 - patch = TBPatch(ops=[MoveEvent(fr=0, to=2)]) - result = apply_tb_ops(plan, patch) - assert result.events[0].n == "Deep work" - assert result.events[1].n == "Lunch" - assert result.events[2].n == "Morning routine" - - def test_move_backward(self) -> None: - plan = _base_plan() - # Move last event to position 0 - patch = TBPatch(ops=[MoveEvent(fr=2, to=0)]) - result = apply_tb_ops(plan, patch) - assert result.events[0].n == "Lunch" - assert result.events[1].n == "Morning routine" - assert result.events[2].n == "Deep work" - - def test_move_same_position(self) -> None: - plan = _base_plan() - patch = TBPatch(ops=[MoveEvent(fr=1, to=1)]) - result = apply_tb_ops(plan, patch) - # Order unchanged - assert [e.n for e in result.events] == ["Morning routine", "Deep work", "Lunch"] - - def test_move_out_of_range(self) -> None: - plan = _base_plan() - patch = TBPatch(ops=[MoveEvent(fr=10, to=0)]) - with pytest.raises(IndexError, match="move"): - apply_tb_ops(plan, patch) - - -# ── Multi-op patches ──────────────────────────────────────────────────── - - -class TestMultiOp: - """Sequential application of multiple ops in a single patch.""" - - def test_add_then_remove(self) -> None: - plan = _base_plan() - patch = TBPatch( - ops=[ - AddEvents(events=[_ev("Temp", st="15:00", t="BU")]), # idx 3 - RemoveEvent(i=3), # remove the event we just added - ] - ) - result = apply_tb_ops(plan, patch) - assert len(result.events) == 3 # net zero change - - def test_remove_then_add(self) -> None: - plan = _base_plan() - patch = TBPatch( - ops=[ - RemoveEvent(i=1), # remove "Deep work" - AddEvents(events=[_ev("Focus time", st="08:00", dur="PT3H")], after=0), - ] - ) - result = apply_tb_ops(plan, patch) - assert len(result.events) == 3 - assert result.events[1].n == "Focus time" - - def test_replace_then_update(self) -> None: - plan = _base_plan() - patch = TBPatch( - ops=[ - ReplaceAll(events=[_ev("Solo", st="09:00", dur="PT1H")]), - UpdateEvent(i=0, n="Renamed Solo"), - ] - ) - result = apply_tb_ops(plan, patch) - assert len(result.events) == 1 - assert result.events[0].n == "Renamed Solo" - - -# ── Discriminated union serialization ──────────────────────────────────── - - -class TestPatchSerialization: - """TBPatch must round-trip through JSON for AutoGen tool calling.""" - - def test_patch_round_trip(self) -> None: - patch = TBPatch( - ops=[ - AddEvents(events=[_ev("A", st="09:00")]), - RemoveEvent(i=0), - UpdateEvent(i=0, n="New"), - MoveEvent(fr=0, to=1), - ReplaceAll(events=[_ev("Z", st="08:00")]), - ] - ) - data = patch.model_dump(mode="json") - restored = TBPatch.model_validate(data) - assert len(restored.ops) == 5 - op_types = [op.op for op in restored.ops] - assert op_types == ["ae", "re", "ue", "me", "ra"] - - def test_discriminator_resolves_correctly(self) -> None: - """Raw JSON with ``op`` discriminator must parse to correct types.""" - raw = { - "ops": [ - { - "op": "ae", - "events": [ - { - "n": "X", - "d": "", - "t": "M", - "p": {"a": "fs", "st": "09:00", "dur": "PT1H"}, - } - ], - }, - {"op": "re", "i": 0}, - {"op": "ue", "i": 0, "n": "Y"}, - {"op": "me", "fr": 0, "to": 1}, - { - "op": "ra", - "events": [ - { - "n": "Z", - "d": "", - "t": "DW", - "p": {"a": "fs", "st": "10:00", "dur": "PT2H"}, - } - ], - }, - ] - } - patch = TBPatch.model_validate(raw) - assert isinstance(patch.ops[0], AddEvents) - assert isinstance(patch.ops[1], RemoveEvent) - assert isinstance(patch.ops[2], UpdateEvent) - assert isinstance(patch.ops[3], MoveEvent) - assert isinstance(patch.ops[4], ReplaceAll) - - def test_json_schema_has_discriminator(self) -> None: - schema = TBPatch.model_json_schema() - assert "$defs" in schema - defs = schema["$defs"] - assert "AddEvents" in defs - assert "RemoveEvent" in defs - - def test_empty_ops_rejected(self) -> None: - with pytest.raises(Exception): - TBPatch(ops=[]) - - def test_add_events_empty_list_rejected(self) -> None: - with pytest.raises(Exception): - AddEvents(events=[]) - - -# ── Immutability ───────────────────────────────────────────────────────── - - -class TestImmutability: - """apply_tb_ops must return a NEW plan, not mutate the original.""" - - def test_original_unchanged_after_add(self) -> None: - plan = _base_plan() - original_count = len(plan.events) - patch = TBPatch(ops=[AddEvents(events=[_ev("Extra", st="15:00")])]) - result = apply_tb_ops(plan, patch) - assert len(plan.events) == original_count - assert len(result.events) == original_count + 1 - - def test_original_unchanged_after_remove(self) -> None: - plan = _base_plan() - original_names = [e.n for e in plan.events] - patch = TBPatch(ops=[RemoveEvent(i=0)]) - apply_tb_ops(plan, patch) - assert [e.n for e in plan.events] == original_names diff --git a/tests/unit/timeboxing/test_timebox_schedule_and_validate.py b/tests/unit/timeboxing/test_timebox_schedule_and_validate.py deleted file mode 100644 index 567696eb..00000000 --- a/tests/unit/timeboxing/test_timebox_schedule_and_validate.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Unit tests for Timebox.schedule_and_validate semantics.""" - -from __future__ import annotations - -from datetime import time - -import pytest - -from fateforger.agents.timeboxing.timebox import Timebox - - -def test_timebox_computes_end_time_from_start_plus_duration() -> None: - """Compute missing end_time when start_time and duration are present.""" - tb = Timebox.model_validate( - { - "date": "2026-01-21", - "timezone": "Europe/Amsterdam", - "events": [ - { - "summary": "Deep Work", - "event_type": "DW", - "start_time": "09:00", - "duration": "PT90M", - "calendarId": "primary", - "timeZone": "Europe/Amsterdam", - } - ], - } - ) - assert tb.events[0].end_time == time(10, 30) - - -def test_timebox_computes_duration_from_start_and_end() -> None: - """Compute missing duration when start_time and end_time are present.""" - tb = Timebox.model_validate( - { - "date": "2026-01-21", - "timezone": "Europe/Amsterdam", - "events": [ - { - "summary": "Meeting", - "event_type": "M", - "start_time": "10:00", - "end_time": "10:30", - "calendarId": "primary", - "timeZone": "Europe/Amsterdam", - } - ], - } - ) - assert tb.events[0].duration is not None - assert tb.events[0].duration.total_seconds() == 30 * 60 - - -def test_timebox_anchors_duration_only_after_previous_when_anchor_prev_true() -> None: - """Anchor a duration-only event after the previous event's end time.""" - tb = Timebox.model_validate( - { - "date": "2026-01-21", - "timezone": "Europe/Amsterdam", - "events": [ - { - "summary": "Meeting", - "event_type": "M", - "start_time": "10:00", - "end_time": "10:30", - "calendarId": "primary", - "timeZone": "Europe/Amsterdam", - }, - { - "summary": "Deep Work", - "event_type": "DW", - "duration": "PT90M", - "anchor_prev": True, - "calendarId": "primary", - "timeZone": "Europe/Amsterdam", - }, - ], - } - ) - assert tb.events[1].start_time == time(10, 30) - assert tb.events[1].end_time == time(12, 0) - - -def test_timebox_anchors_duration_only_before_next_when_anchor_prev_false() -> None: - """Anchor a duration-only event before the next event's start time.""" - tb = Timebox.model_validate( - { - "date": "2026-01-21", - "timezone": "Europe/Amsterdam", - "events": [ - { - "summary": "Meeting 1", - "event_type": "M", - "start_time": "10:00", - "end_time": "10:30", - "calendarId": "primary", - "timeZone": "Europe/Amsterdam", - }, - { - "summary": "Buffer", - "event_type": "BU", - "duration": "PT30M", - "anchor_prev": False, - "calendarId": "primary", - "timeZone": "Europe/Amsterdam", - }, - { - "summary": "Meeting 2", - "event_type": "M", - "start_time": "11:00", - "end_time": "11:30", - "calendarId": "primary", - "timeZone": "Europe/Amsterdam", - }, - ], - } - ) - assert tb.events[1].start_time == time(10, 30) - assert tb.events[1].end_time == time(11, 0) - - -def test_timebox_rejects_overlaps() -> None: - """Reject overlapping event schedules.""" - with pytest.raises(ValueError, match="Overlap"): - Timebox.model_validate( - { - "date": "2026-01-21", - "timezone": "Europe/Amsterdam", - "events": [ - { - "summary": "A", - "event_type": "M", - "start_time": "10:00", - "end_time": "10:30", - "calendarId": "primary", - "timeZone": "Europe/Amsterdam", - }, - { - "summary": "B", - "event_type": "M", - "start_time": "10:15", - "end_time": "10:45", - "calendarId": "primary", - "timeZone": "Europe/Amsterdam", - }, - ], - } - ) - - -def test_timebox_uses_datetime_start_end_when_time_fields_missing() -> None: - """Derive start_time/end_time from datetime start/end anchors.""" - tb = Timebox.model_validate( - { - "date": "2026-01-21", - "timezone": "Europe/Amsterdam", - "events": [ - { - "summary": "Anchored Meeting", - "event_type": "M", - "start": "2026-01-21T09:00:00", - "end": "2026-01-21T10:00:00", - "calendarId": "primary", - "timeZone": "Europe/Amsterdam", - } - ], - } - ) - assert tb.events[0].start_time == time(9, 0) - assert tb.events[0].end_time == time(10, 0) - - -def test_timebox_missing_anchor_error_uses_summary_label() -> None: - """Raise ValueError with summary label instead of crashing on missing `uid`.""" - with pytest.raises(ValueError, match="Unscheduled Event: needs start or duration"): - Timebox.model_validate( - { - "date": "2026-01-21", - "timezone": "Europe/Amsterdam", - "events": [ - { - "summary": "Unscheduled Event", - "event_type": "DW", - "duration": "PT30M", - "anchor_prev": True, - "calendarId": "primary", - "timeZone": "Europe/Amsterdam", - } - ], - } - ) - - -def test_timebox_missing_anchor_error_prefers_event_id_label() -> None: - """Raise ValueError using eventId/uid-compatible label when present.""" - with pytest.raises(ValueError, match="evt-123: needs start or duration"): - Timebox.model_validate( - { - "date": "2026-01-21", - "timezone": "Europe/Amsterdam", - "events": [ - { - "eventId": "evt-123", - "summary": "Ignored Label", - "event_type": "DW", - "duration": "PT30M", - "anchor_prev": True, - "calendarId": "primary", - "timeZone": "Europe/Amsterdam", - } - ], - } - ) diff --git a/tests/unit/timeboxing/test_timeboxing_calendar_prefetch_feedback.py b/tests/unit/timeboxing/test_timeboxing_calendar_prefetch_feedback.py deleted file mode 100644 index 0f584412..00000000 --- a/tests/unit/timeboxing/test_timeboxing_calendar_prefetch_feedback.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Unit tests for calendar prefetch feedback messaging.""" - -from __future__ import annotations - -import asyncio -from datetime import date - -import pytest - -pytest.importorskip("autogen_agentchat") - -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent - - -@pytest.mark.asyncio -async def test_prefetch_calendar_adds_note_when_client_unavailable() -> None: - """Prefetch should surface a user-visible note when calendar client is unavailable.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._ensure_calendar_client = lambda: None - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-02-14", - tz_name="Europe/Amsterdam", - ) - - await TimeboxingFlowAgent._prefetch_calendar_immovables( - agent, session, "2026-02-14" - ) - - assert ( - "Calendar integration is unavailable right now; share fixed events manually." - in session.background_updates - ) - - -@pytest.mark.asyncio -async def test_prefetch_calendar_error_note_is_deduplicated() -> None: - """Prefetch failure note should be appended once across repeated failures.""" - - class _FailingCalendarClient: - async def list_day_immovables( - self, - *, - calendar_id: str, - day: date, - tz, - diagnostics: dict | None = None, - ) -> list[dict[str, str]]: - _ = (calendar_id, day, tz, diagnostics) - raise RuntimeError("calendar MCP unavailable") - - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._ensure_calendar_client = lambda: _FailingCalendarClient() - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-02-14", - tz_name="Europe/Amsterdam", - ) - - await TimeboxingFlowAgent._prefetch_calendar_immovables( - agent, session, "2026-02-14" - ) - await TimeboxingFlowAgent._prefetch_calendar_immovables( - agent, session, "2026-02-14" - ) - - note = "Couldn't load calendar events yet; share fixed anchors manually or click Redo." - assert session.background_updates.count(note) == 1 - - -@pytest.mark.asyncio -async def test_prefetch_calendar_force_refresh_bypasses_cached_snapshot() -> None: - """force_refresh should ignore day-cache and fetch live snapshot again.""" - - class _Snapshot: - def __init__(self, suffix: str) -> None: - self.immovables = [{"title": f"Lunch {suffix}", "start": "13:00", "end": "14:00"}] - self.response = { - "events": [ - { - "summary": f"Lunch {suffix}", - "start": { - "dateTime": "2026-02-14T13:00:00+01:00", - "timeZone": "Europe/Amsterdam", - }, - "end": { - "dateTime": "2026-02-14T14:00:00+01:00", - "timeZone": "Europe/Amsterdam", - }, - } - ] - } - - class _CalendarClient: - def __init__(self) -> None: - self.calls = 0 - - async def list_day_snapshot( - self, - *, - calendar_id: str, - day: date, - tz, - diagnostics: dict | None = None, - ) -> _Snapshot: - _ = (calendar_id, day, tz, diagnostics) - self.calls += 1 - return _Snapshot(suffix=f"R{self.calls}") - - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - client = _CalendarClient() - agent._ensure_calendar_client = lambda: client - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-02-14", - tz_name="Europe/Amsterdam", - ) - session.prefetched_immovables_by_date["2026-02-14"] = [ - {"title": "Lunch stale", "start": "13:00", "end": "14:00"} - ] - session.prefetched_remote_snapshots_by_date["2026-02-14"] = object() - - await TimeboxingFlowAgent._prefetch_calendar_immovables( - agent, - session, - "2026-02-14", - force_refresh=True, - ) - - assert client.calls == 1 - assert session.prefetched_immovables_by_date["2026-02-14"][0]["title"] == "Lunch R1" - - -@pytest.mark.asyncio -async def test_ensure_calendar_timeout_keeps_prefetch_running_in_background( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Timeout in ensure should not cancel the in-flight calendar prefetch task.""" - - async def _slow_prefetch( - self: TimeboxingFlowAgent, session: Session, planned_date: str - ) -> None: - _ = self - await asyncio.sleep(0.05) - session.prefetched_immovables_by_date[planned_date] = [ - {"title": "Meeting", "start": "10:00", "end": "11:00"} - ] - - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-02-14", - tz_name="Europe/Amsterdam", - ) - monkeypatch.setattr( - TimeboxingFlowAgent, "_prefetch_calendar_immovables", _slow_prefetch - ) - - await TimeboxingFlowAgent._ensure_calendar_immovables(agent, session, timeout_s=0.01) - - assert ( - "Calendar fetch timed out; share fixed anchors manually or click Redo." - in session.background_updates - ) - await asyncio.sleep(0.08) - TimeboxingFlowAgent._apply_prefetched_calendar_immovables(agent, session) - assert session.frame_facts.get("immovables") - - -def test_apply_prefetched_calendar_immovables_merges_existing_rows() -> None: - """Prefetched anchors should be merged with existing rows, not skipped.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._session_debug = lambda *_args, **_kwargs: None - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-02-14", - tz_name="Europe/Amsterdam", - ) - session.prefetched_immovables_by_date["2026-02-14"] = [ - {"title": "Planning", "start": "16:00", "end": "17:00"}, - ] - session.frame_facts["immovables"] = [ - {"title": "Lunch", "start": "13:00", "end": "14:00"}, - {"title": "Planning", "start": "16:00", "end": "17:00"}, - ] - - TimeboxingFlowAgent._apply_prefetched_calendar_immovables(agent, session) - - immovables = session.frame_facts.get("immovables") - assert isinstance(immovables, list) - assert len(immovables) == 2 - assert immovables[0]["title"] == "Planning" - assert immovables[1]["title"] == "Lunch" diff --git a/tests/unit/timeboxing/test_timeboxing_commit_skips_initial_extraction.py b/tests/unit/timeboxing/test_timeboxing_commit_skips_initial_extraction.py deleted file mode 100644 index 573106c9..00000000 --- a/tests/unit/timeboxing/test_timeboxing_commit_skips_initial_extraction.py +++ /dev/null @@ -1,66 +0,0 @@ -import types - -import pytest - -pytest.importorskip("autogen_agentchat") - -from autogen_agentchat.messages import TextMessage - -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.messages import TimeboxingCommitDate - - -class _Ctx: - topic_id = None - sender = None - - -@pytest.mark.asyncio -async def test_commit_does_not_trigger_initial_extraction(): - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._sessions = {} - agent._durable_constraint_prefetch_tasks = {} - - async def _fake_run_graph_turn(*, session, user_text): - return TextMessage(content="ok", source="timeboxing_agent") - - async def _fake_publish_update(**_kwargs): - return None - - async def _fake_prefetch_calendar(_session, _date): - return None - - agent._run_graph_turn = _fake_run_graph_turn - agent._publish_update = _fake_publish_update - agent._prefetch_calendar_immovables = _fake_prefetch_calendar - agent._apply_prefetched_calendar_immovables = lambda _session: None - agent._queue_constraint_prefetch = lambda _session: None - - called = False - - def _fake_queue_constraint_extraction(**_kwargs): - nonlocal called - called = True - return None - - agent._queue_constraint_extraction = _fake_queue_constraint_extraction - - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - last_user_message="Start timeboxing", - ) - agent._sessions["t1"] = session - - msg = TimeboxingCommitDate( - channel_id="c1", - thread_ts="t1", - user_id="u1", - planned_date="2026-01-21", - timezone="Europe/Amsterdam", - ) - - await agent.on_commit_date(msg, _Ctx()) - - assert called is False diff --git a/tests/unit/timeboxing/test_timeboxing_constants.py b/tests/unit/timeboxing/test_timeboxing_constants.py deleted file mode 100644 index 4438b376..00000000 --- a/tests/unit/timeboxing/test_timeboxing_constants.py +++ /dev/null @@ -1,53 +0,0 @@ -"""The tunable constants of a timeboxing session, and the timeouts derived -from them. -""" - -from __future__ import annotations - -import pytest -from fateforger.agents.timeboxing.constants import TIMEBOXING_TIMEOUTS - - -# ── constants ───────────────────────────────────────────────────────────────── - -class TestStageGateTimeout: - """Timeout values must be grounded in observed runtime latency.""" - - def test_stage_gate_timeout_exceeds_observed_p95_latency_with_margin(self) -> None: - """stage_gate_s must be >= 60s. - - Production observation (2026-03-12, session 1773337741.092819): - graph_turn_slow fired at elapsed_s=46.011 while gate was still running. - The 35s default was too tight. 60s provides a safe margin over p95. - """ - assert TIMEBOXING_TIMEOUTS.stage_gate_s >= 60.0, ( - f"stage_gate_s={TIMEBOXING_TIMEOUTS.stage_gate_s} is below the 60s " - "minimum needed to survive observed p95 LLM latency (~46s). " - "Raise it in constants.py." - ) - - def test_stage_gate_timeout_leaves_budget_inside_graph_turn(self) -> None: - """stage_gate_s must leave at least 30s of headroom inside graph_turn_s. - - The graph turn does more than just run the stage gate (constraint loading, - calendar prefetch, presenter formatting). The gate must not consume the - full turn budget. - """ - headroom = TIMEBOXING_TIMEOUTS.graph_turn_s - TIMEBOXING_TIMEOUTS.stage_gate_s - assert headroom >= 30.0, ( - f"stage_gate_s={TIMEBOXING_TIMEOUTS.stage_gate_s} leaves only " - f"{headroom:.1f}s inside graph_turn_s={TIMEBOXING_TIMEOUTS.graph_turn_s}. " - "Need at least 30s of headroom." - ) - - -# ── timeouts ────────────────────────────────────────────────────────────────── - -def test_stage_gate_timeout_budget_has_headroom() -> None: - """Stage-gate LLM calls should have enough budget for real Slack runs.""" - assert TIMEBOXING_TIMEOUTS.stage_gate_s >= 35.0 - - -def test_slow_turn_warning_threshold_is_set() -> None: - """Slow-turn telemetry should have a deterministic threshold.""" - assert TIMEBOXING_TIMEOUTS.slow_turn_warn_s >= 30.0 diff --git a/tests/unit/timeboxing/test_timeboxing_graph_turn_lock.py b/tests/unit/timeboxing/test_timeboxing_graph_turn_lock.py deleted file mode 100644 index 3efeb5bd..00000000 --- a/tests/unit/timeboxing/test_timeboxing_graph_turn_lock.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Regression tests for per-session GraphFlow turn serialization.""" - -from __future__ import annotations - -import asyncio -from dataclasses import replace - -import pytest -from autogen_agentchat.messages import TextMessage -from autogen_core import AgentId - -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.constants import TIMEBOXING_TIMEOUTS - - -class _ConcurrentUnsafeFlow: - """Flow test double that raises if two run_stream calls overlap.""" - - def __init__(self) -> None: - self.running = 0 - self.max_running = 0 - self.calls = 0 - - async def run_stream(self, task: TextMessage): # type: ignore[override] - self.calls += 1 - self.running += 1 - self.max_running = max(self.max_running, self.running) - try: - if self.running > 1: - raise ValueError( - "The team is already running, it cannot run again until it is stopped." - ) - await asyncio.sleep(0.03) - yield TextMessage(content=f"ok:{task.content}", source="PresenterNode") - finally: - self.running -= 1 - - -@pytest.mark.asyncio -async def test_run_graph_turn_serializes_concurrent_calls_per_session() -> None: - """Concurrent user replies should not overlap GraphFlow.run_stream for one session.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._id = AgentId("timeboxing_agent", "test-key") - agent._refresh_temporal_facts = lambda _session: None - agent._session_debug = lambda *_args, **_kwargs: None - - flow = _ConcurrentUnsafeFlow() - agent._ensure_graphflow = lambda _session: flow - - session = Session( - thread_ts="thread-1", - channel_id="C123", - user_id="U123", - committed=True, - planned_date="2026-02-27", - ) - - first = TimeboxingFlowAgent._run_graph_turn(agent, session=session, user_text="a") - second = TimeboxingFlowAgent._run_graph_turn(agent, session=session, user_text="b") - out_a, out_b = await asyncio.gather(first, second) - - assert isinstance(out_a, TextMessage) - assert isinstance(out_b, TextMessage) - assert flow.calls == 2 - assert flow.max_running == 1 - assert session.graph_turn_started_at_monotonic is None - assert session.graph_turn_deadline_monotonic is None - - -@pytest.mark.asyncio -async def test_run_graph_turn_returns_timeout_message_when_outer_timeout_hits( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """A stuck graph turn should return a deterministic timeout reply instead of hanging.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._id = AgentId("timeboxing_agent", "test-key") - agent._refresh_temporal_facts = lambda _session: None - - events: list[str] = [] - - def _session_debug(_session: Session, event: str, **_payload: object) -> None: - events.append(event) - - agent._session_debug = _session_debug - - class _SlowFlow: - async def run_stream(self, task: TextMessage): # type: ignore[override] - await asyncio.sleep(0.2) - yield TextMessage(content=f"late:{task.content}", source="PresenterNode") - - flow = _SlowFlow() - agent._ensure_graphflow = lambda _session: flow - - monkeypatch.setattr( - "fateforger.agents.timeboxing.agent.TIMEBOXING_TIMEOUTS", - replace(TIMEBOXING_TIMEOUTS, graph_turn_s=0.01), - ) - - session = Session( - thread_ts="thread-timeout", - channel_id="C123", - user_id="U123", - committed=True, - planned_date="2026-02-27", - ) - - out = await TimeboxingFlowAgent._run_graph_turn( - agent, session=session, user_text="stuck" - ) - - assert isinstance(out, TextMessage) - assert "processing timeout" in out.content - assert events.count("graph_turn_timeout") == 1 - assert events.count("graph_turn_end") == 1 - assert session.graph_turn_started_at_monotonic is None - assert session.graph_turn_deadline_monotonic is None diff --git a/tests/unit/timeboxing/test_timeboxing_graphflow_state_machine.py b/tests/unit/timeboxing/test_timeboxing_graphflow_state_machine.py deleted file mode 100644 index 021ac684..00000000 --- a/tests/unit/timeboxing/test_timeboxing_graphflow_state_machine.py +++ /dev/null @@ -1,373 +0,0 @@ -import types - -import pytest - -pytest.importorskip("autogen_agentchat") - -from autogen_agentchat.messages import TextMessage - -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.flow_graph import build_timeboxing_graphflow -from fateforger.agents.timeboxing.nodes.nodes import TransitionNode -from fateforger.agents.timeboxing.patching import TimeboxPatcher -from fateforger.agents.timeboxing.stage_gating import ( - StageDecision, - StageGateOutput, - TimeboxingStage, -) - - -@pytest.mark.asyncio -async def test_graphflow_routes_by_session_stage_and_decision(): - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._timebox_patcher = TimeboxPatcher() - agent._durable_constraint_prefetch_tasks = {} - - async def _noop_calendar( - _self, _session: Session, *, timeout_s: float = 0.0 - ) -> None: - return None - - def _noop_queue_extract(**_kwargs): - return None - - async def _fake_decide( - _self, _session: Session, *, user_message: str - ) -> StageDecision: - assert user_message == "hello" - return StageDecision(action="provide_info") - - async def _fake_stage_gate( - _self, - *, - stage: TimeboxingStage, - user_message: str, - context: dict, - ) -> StageGateOutput: - assert user_message == "hello" - return StageGateOutput( - stage_id=stage, - ready=False, - summary=[f"saw:{stage.value}"], - missing=["x"], - question="q?", - facts={}, - ) - - def _fake_format(_self, gate: StageGateOutput, **_kwargs) -> str: - return f"{gate.stage_id.value}:{gate.ready}:{gate.question}" - - agent._ensure_calendar_immovables = types.MethodType(_noop_calendar, agent) - agent._queue_constraint_extraction = _noop_queue_extract # type: ignore[assignment] - agent._decide_next_action = types.MethodType(_fake_decide, agent) - agent._run_stage_gate = types.MethodType(_fake_stage_gate, agent) - agent._collect_background_notes = lambda _session: None # type: ignore[assignment] - agent._format_stage_message = types.MethodType(_fake_format, agent) - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", committed=True) - session.stage = TimeboxingStage.COLLECT_CONSTRAINTS - - flow = build_timeboxing_graphflow(orchestrator=agent, session=session) - - out: TextMessage | None = None - async for item in flow.run_stream(task=TextMessage(content="hello", source="user")): - if isinstance(item, TextMessage) and item.source == "PresenterNode": - out = item - assert out is not None - assert out.content.startswith("CollectConstraints:") - - -@pytest.mark.asyncio -async def test_graphflow_proceed_advances_to_next_stage(): - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._timebox_patcher = TimeboxPatcher() - agent._durable_constraint_prefetch_tasks = {} - - async def _noop_calendar( - _self, _session: Session, *, timeout_s: float = 0.0 - ) -> None: - return None - - def _noop_queue_extract(**_kwargs): - return None - - async def _fake_decide( - _self, _session: Session, *, user_message: str - ) -> StageDecision: - return StageDecision(action="proceed") - - async def _fake_stage_gate( - _self, - *, - stage: TimeboxingStage, - user_message: str, - context: dict, - ) -> StageGateOutput: - # proceed runs next stage with empty user_message - assert user_message == "" - return StageGateOutput( - stage_id=stage, - ready=True, - summary=[f"ran:{stage.value}"], - missing=[], - question=None, - facts={}, - ) - - def _fake_format(_self, gate: StageGateOutput, **_kwargs) -> str: - return f"STAGE={gate.stage_id.value}" - - agent._ensure_calendar_immovables = types.MethodType(_noop_calendar, agent) - agent._queue_constraint_extraction = _noop_queue_extract # type: ignore[assignment] - agent._decide_next_action = types.MethodType(_fake_decide, agent) - agent._run_stage_gate = types.MethodType(_fake_stage_gate, agent) - agent._collect_background_notes = lambda _session: None # type: ignore[assignment] - agent._format_stage_message = types.MethodType(_fake_format, agent) - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", committed=True) - session.stage = TimeboxingStage.COLLECT_CONSTRAINTS - session.stage_ready = True # allow proceed without override - - flow = build_timeboxing_graphflow(orchestrator=agent, session=session) - out: TextMessage | None = None - async for item in flow.run_stream(task=TextMessage(content="go", source="user")): - if isinstance(item, TextMessage) and item.source == "PresenterNode": - out = item - assert out is not None - assert out.content == "STAGE=CaptureInputs" - - -@pytest.mark.asyncio -async def test_graphflow_cancel_terminates_without_stage_run(): - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._timebox_patcher = TimeboxPatcher() - - async def _noop_calendar( - _self, _session: Session, *, timeout_s: float = 0.0 - ) -> None: - return None - - def _noop_queue_extract(**_kwargs): - return None - - async def _fake_decide( - _self, _session: Session, *, user_message: str - ) -> StageDecision: - return StageDecision(action="cancel") - - agent._ensure_calendar_immovables = types.MethodType(_noop_calendar, agent) - agent._queue_constraint_extraction = _noop_queue_extract # type: ignore[assignment] - agent._decide_next_action = types.MethodType(_fake_decide, agent) - agent._collect_background_notes = lambda _session: None # type: ignore[assignment] - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", committed=True) - session.stage = TimeboxingStage.CAPTURE_INPUTS - - flow = build_timeboxing_graphflow(orchestrator=agent, session=session) - out: TextMessage | None = None - async for item in flow.run_stream(task=TextMessage(content="stop", source="user")): - if isinstance(item, TextMessage) and item.source == "PresenterNode": - out = item - assert out is not None - assert out.content == "Okayβ€”stopping this timeboxing session." - assert session.thread_state == "canceled" - - -@pytest.mark.asyncio -async def test_transition_assist_prioritizes_memory_review_over_task_assist(): - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", committed=True) - session.stage = TimeboxingStage.COLLECT_CONSTRAINTS - calls = {"assist": 0} - - async def _memory_review( - _self, *, session: Session, user_message: str - ): # noqa: ARG001 - return TextMessage(content="memory-reviewed", source="timeboxing_agent") - - async def _assist_turn( - _self, - *, - session: Session, - user_message: str, - note: str | None, - assist_target: str | None, - ): # noqa: ARG001 - calls["assist"] += 1 - return "assist" - - agent._maybe_handle_memory_review_turn = types.MethodType(_memory_review, agent) - agent._run_assist_turn = types.MethodType(_assist_turn, agent) - turn_init = types.SimpleNamespace( - turn=types.SimpleNamespace( - decision=StageDecision(action="assist", note="adjacent"), - user_text="Which constraints are currently active?", - ) - ) - node = TransitionNode(orchestrator=agent, session=session, turn_init=turn_init) - - out = await node.on_messages([], cancellation_token=types.SimpleNamespace()) - - assert session.last_response == "memory-reviewed" - assert session.skip_stage_execution is True - assert node.stage_user_message == "" - assert out.chat_message.content.note == "memory_review" - assert calls["assist"] == 0 - - -@pytest.mark.asyncio -async def test_transition_assist_falls_through_when_memory_review_not_selected(): - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", committed=True) - session.stage = TimeboxingStage.COLLECT_CONSTRAINTS - calls = {"assist": 0} - - async def _memory_review( - _self, *, session: Session, user_message: str - ): # noqa: ARG001 - return None - - async def _assist_turn( - _self, - *, - session: Session, - user_message: str, - note: str | None, - assist_target: str | None, - ): # noqa: ARG001 - calls["assist"] += 1 - return "assist-routed" - - agent._maybe_handle_memory_review_turn = types.MethodType(_memory_review, agent) - agent._run_assist_turn = types.MethodType(_assist_turn, agent) - turn_init = types.SimpleNamespace( - turn=types.SimpleNamespace( - decision=StageDecision(action="assist", note="adjacent"), - user_text="show pending tasks", - ) - ) - node = TransitionNode(orchestrator=agent, session=session, turn_init=turn_init) - - out = await node.on_messages([], cancellation_token=types.SimpleNamespace()) - - assert session.last_response is None - assert session.skip_stage_execution is False - assert node.stage_user_message == "show pending tasks" - assert out.chat_message.content.note == "rerun" - assert calls["assist"] == 0 - - -@pytest.mark.asyncio -async def test_transition_assist_routes_only_on_explicit_target_and_confidence(): - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", committed=True) - session.stage = TimeboxingStage.COLLECT_CONSTRAINTS - calls = {"assist": 0} - - async def _memory_review( - _self, *, session: Session, user_message: str - ): # noqa: ARG001 - return None - - async def _assist_turn( - _self, - *, - session: Session, - user_message: str, - note: str | None, - assist_target: str | None, - ): # noqa: ARG001 - calls["assist"] += 1 - assert assist_target == "tasks_agent" - return "assist-routed" - - agent._maybe_handle_memory_review_turn = types.MethodType(_memory_review, agent) - agent._run_assist_turn = types.MethodType(_assist_turn, agent) - turn_init = types.SimpleNamespace( - turn=types.SimpleNamespace( - decision=StageDecision( - action="assist", - assist_target="tasks_agent", - assist_confidence=0.95, - note="adjacent", - ), - user_text="show pending tasks", - ) - ) - node = TransitionNode(orchestrator=agent, session=session, turn_init=turn_init) - - out = await node.on_messages([], cancellation_token=types.SimpleNamespace()) - - assert session.last_response == "assist-routed" - assert session.skip_stage_execution is True - assert node.stage_user_message == "" - assert out.chat_message.content.note == "assist" - assert calls["assist"] == 1 - - -@pytest.mark.asyncio -async def test_transition_routes_reviewcommit_edits_back_to_refine(): - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - - async def _advance_stage( - _self, - session: Session, - *, - next_stage: TimeboxingStage, - ) -> None: - session.stage = next_stage - - agent._advance_stage = types.MethodType(_advance_stage, agent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", committed=True) - session.stage = TimeboxingStage.REVIEW_COMMIT - - turn_init = types.SimpleNamespace( - turn=types.SimpleNamespace( - decision=StageDecision(action="provide_info"), - user_text="please add a lunch block and a buffer", - ) - ) - node = TransitionNode(orchestrator=agent, session=session, turn_init=turn_init) - - await node.on_messages([], cancellation_token=types.SimpleNamespace()) - - assert session.stage == TimeboxingStage.REFINE - assert node.stage_user_message == "please add a lunch block and a buffer" - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "start_stage", - [TimeboxingStage.COLLECT_CONSTRAINTS, TimeboxingStage.CAPTURE_INPUTS], -) -async def test_transition_routes_target_stage_refine_for_edit_intents(start_stage): - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - - async def _advance_stage( - _self, - session: Session, - *, - next_stage: TimeboxingStage, - ) -> None: - session.stage = next_stage - - agent._advance_stage = types.MethodType(_advance_stage, agent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1", committed=True) - session.stage = start_stage - - turn_init = types.SimpleNamespace( - turn=types.SimpleNamespace( - decision=StageDecision( - action="provide_info", - target_stage=TimeboxingStage.REFINE, - note="user requested schedule edit", - ), - user_text="move deep work to 12:30 and keep shutdown at 22:30", - ) - ) - node = TransitionNode(orchestrator=agent, session=session, turn_init=turn_init) - - await node.on_messages([], cancellation_token=types.SimpleNamespace()) - - assert session.stage == TimeboxingStage.REFINE - assert node.stage_user_message == "move deep work to 12:30 and keep shutdown at 22:30" diff --git a/tests/unit/timeboxing/test_timeboxing_mcp_calendar_client.py b/tests/unit/timeboxing/test_timeboxing_mcp_calendar_client.py deleted file mode 100644 index a1c4e28a..00000000 --- a/tests/unit/timeboxing/test_timeboxing_mcp_calendar_client.py +++ /dev/null @@ -1,423 +0,0 @@ -"""Unit tests for timeboxing calendar MCP payload parsing.""" - -from __future__ import annotations - -import json -from datetime import date -from typing import Any -from zoneinfo import ZoneInfo - -import pytest - -from fateforger.agents.timeboxing.mcp_clients import McpCalendarClient - -pytest.importorskip("autogen_ext.tools.mcp") - - -class _FakeToolResult: - """Minimal MCP tool result with ``to_text`` for JSON payload parsing.""" - - def __init__(self, payload: str) -> None: - self._payload = payload - - def to_text(self) -> str: - """Return serialized payload text.""" - return self._payload - - -class _FakeWorkbench: - """Fake MCP workbench for list-events calls.""" - - def __init__(self, payload: dict) -> None: - self._payload = payload - self.last_arguments: dict | None = None - - async def call_tool(self, name: str, arguments: dict) -> _FakeToolResult: - """Return a deterministic list-events response.""" - assert name == "list-events" - self.last_arguments = dict(arguments) - return _FakeToolResult(json.dumps(self._payload)) - - -class _SequenceWorkbench: - """Fake workbench that replays deterministic call outcomes.""" - - def __init__(self, outcomes: list[Any]) -> None: - self._outcomes = list(outcomes) - self.calls = 0 - - async def call_tool(self, name: str, arguments: dict) -> Any: - assert name == "list-events" - self.calls += 1 - if not self._outcomes: - raise AssertionError("No more fake outcomes available") - outcome = self._outcomes.pop(0) - if isinstance(outcome, Exception): - raise outcome - return outcome - - -class _ResultTextItem: - """Mimic MCP text payload item wrapper.""" - - def __init__(self, content: str) -> None: - self.content = content - - -class _FakeWrappedResult: - """Mimic a tool result that stores JSON text under ``result[].content``.""" - - def __init__(self, content: str) -> None: - self.result = [_ResultTextItem(content)] - - -def test_normalize_events_accepts_events_key() -> None: - """Calendar normalization should accept the ``events`` response shape.""" - payload = {"events": [{"summary": "Meeting"}], "totalCount": 1} - events = McpCalendarClient._normalize_events(payload) - assert len(events) == 1 - assert events[0]["summary"] == "Meeting" - - -class TestNormalizeEvents: - """Full branch coverage for _normalize_events structural normalizer.""" - - _ev = { - "summary": "S", - "start": {"dateTime": "2025-01-01T09:00:00Z"}, - "end": {"dateTime": "2025-01-01T10:00:00Z"}, - } - - def _call(self, payload: Any) -> list[dict[str, Any]]: - return McpCalendarClient._normalize_events(payload) - - def test_dict_events_key(self) -> None: - assert len(self._call({"events": [self._ev], "totalCount": 1})) == 1 - - def test_dict_items_key(self) -> None: - assert len(self._call({"items": [self._ev]})) == 1 - - def test_dict_single_event_key(self) -> None: - result = self._call({"event": self._ev}) - assert len(result) == 1 and result[0] is self._ev - - def test_dict_empty(self) -> None: - assert self._call({}) == [] - - def test_list_of_direct_events(self) -> None: - assert len(self._call([self._ev, self._ev])) == 2 - - def test_list_prefers_start_end_items(self) -> None: - plain = {"foo": "bar"} - result = self._call([plain, self._ev]) - # items with start/end are preferred - assert all("start" in item for item in result) - - def test_list_nested_payload(self) -> None: - nested = {"events": [self._ev]} - result = self._call([nested]) - assert len(result) == 1 - - def test_empty_list(self) -> None: - assert self._call([]) == [] - - def test_scalar_returns_empty(self) -> None: - assert self._call("not-a-list") == [] - - def test_none_returns_empty(self) -> None: - assert self._call(None) == [] - - def test_non_dict_items_in_list_are_skipped(self) -> None: - # Non-dict items in a list should be ignored - result = self._call([42, "string", self._ev]) - assert all(isinstance(item, dict) for item in result) - - -def test_extract_tool_payload_parses_wrapped_result_content() -> None: - """Tool payload extraction should parse JSON from wrapped text content.""" - wrapped = _FakeWrappedResult('{"events":[{"summary":"Deep work"}]}') - payload = McpCalendarClient._extract_tool_payload(wrapped) - assert isinstance(payload, dict) - assert payload.get("events")[0]["summary"] == "Deep work" - - -def test_extract_tool_payload_raises_on_non_json_text() -> None: - """Tool payload extraction must fail loudly on invalid JSON text.""" - - class _InvalidTextResult: - def to_text(self) -> str: - return "not-json" - - with pytest.raises(RuntimeError): - McpCalendarClient._extract_tool_payload(_InvalidTextResult()) - - -@pytest.mark.asyncio -async def test_list_day_immovables_reads_events_payload_shape() -> None: - """list_day_immovables should return anchors from MCP ``events`` payloads.""" - client = McpCalendarClient.__new__(McpCalendarClient) - client._workbench = _FakeWorkbench( - payload={ - "events": [ - { - "id": "test-brunch-event", - "summary": "Brunch", - "status": "confirmed", - "start": {"dateTime": "2026-02-14T11:30:00+01:00"}, - "end": {"dateTime": "2026-02-14T13:00:00+01:00"}, - } - ], - "totalCount": 1, - } - ) - diagnostics: dict[str, object] = {} - - events = await client.list_day_immovables( - calendar_id="primary", - day=date(2026, 2, 14), - tz=ZoneInfo("Europe/Amsterdam"), - diagnostics=diagnostics, - ) - - assert events == [{"title": "Brunch", "start": "11:30", "end": "13:00"}] - assert diagnostics.get("raw_event_count") == 1 - assert diagnostics.get("immovable_count") == 1 - - -@pytest.mark.asyncio -async def test_list_day_snapshot_uses_iso_without_timezone_suffix() -> None: - """list-events args should use MCP-compatible ISO datetime strings.""" - workbench = _FakeWorkbench(payload={"events": [], "totalCount": 0}) - client = McpCalendarClient.__new__(McpCalendarClient) - client._workbench = workbench - - await client.list_day_snapshot( - calendar_id="primary", - day=date(2026, 2, 14), - tz=ZoneInfo("Europe/Amsterdam"), - diagnostics={}, - ) - - assert workbench.last_arguments is not None - time_min = str(workbench.last_arguments["timeMin"]) - time_max = str(workbench.last_arguments["timeMax"]) - assert time_min == "2026-02-14T00:00:00" - assert time_max == "2026-02-15T00:00:00" - assert "+" not in time_min and "Z" not in time_min - assert "+" not in time_max and "Z" not in time_max - - -@pytest.mark.asyncio -async def test_get_tools_raises_when_loader_returns_empty( - monkeypatch: pytest.MonkeyPatch, -) -> None: - import autogen_ext.tools.mcp as mcp_mod - - async def _empty_loader(_params): - return [] - - monkeypatch.setattr(mcp_mod, "mcp_server_tools", _empty_loader) - client = McpCalendarClient.__new__(McpCalendarClient) - client._params = object() - - with pytest.raises(RuntimeError, match="calendar MCP server returned no tools"): - await client.get_tools() - - -@pytest.mark.asyncio -async def test_list_day_snapshot_recovers_after_transport_connect_failure() -> None: - """Client should reinitialize once after recoverable transport failure.""" - failing = _SequenceWorkbench([RuntimeError("All connection attempts failed")]) - healthy = _SequenceWorkbench([_FakeToolResult('{"events":[],"totalCount":0}')]) - client = McpCalendarClient.__new__(McpCalendarClient) - client._workbench = failing - reset_calls = 0 - - async def _reset_workbench() -> None: - nonlocal reset_calls - reset_calls += 1 - client._workbench = healthy - - client._reset_workbench = _reset_workbench # type: ignore[method-assign] - diagnostics: dict[str, object] = {} - - snapshot = await client.list_day_snapshot( - calendar_id="primary", - day=date(2026, 2, 14), - tz=ZoneInfo("Europe/Amsterdam"), - diagnostics=diagnostics, - ) - - assert snapshot.immovables == [] - assert reset_calls == 1 - assert failing.calls == 1 - assert healthy.calls == 1 - assert diagnostics["attempt_errors"][0]["recoverable"] is True # type: ignore[index] - - -@pytest.mark.asyncio -async def test_list_day_snapshot_recovers_after_actor_not_running_payload() -> None: - """Client should retry once when MCP actor session is in a dead state.""" - failing = _SequenceWorkbench( - [_FakeToolResult("MCP Actor not running, call initialize() first")] - ) - healthy = _SequenceWorkbench([_FakeToolResult('{"events":[],"totalCount":0}')]) - client = McpCalendarClient.__new__(McpCalendarClient) - client._workbench = failing - reset_calls = 0 - - async def _reset_workbench() -> None: - nonlocal reset_calls - reset_calls += 1 - client._workbench = healthy - - client._reset_workbench = _reset_workbench # type: ignore[method-assign] - diagnostics: dict[str, object] = {} - - snapshot = await client.list_day_snapshot( - calendar_id="primary", - day=date(2026, 2, 14), - tz=ZoneInfo("Europe/Amsterdam"), - diagnostics=diagnostics, - ) - - assert snapshot.immovables == [] - assert reset_calls == 1 - assert failing.calls == 1 - assert healthy.calls == 1 - assert diagnostics["attempt_errors"][0]["recoverable"] is True # type: ignore[index] - - -# --------------------------------------------------------------------------- -# Unit tests for _parse_event_dt (Pydantic EventDateTime dispatch) -# --------------------------------------------------------------------------- - - -class TestParseEventDt: - """_parse_event_dt delegates wholesale to EventDateTime.to_datetime().""" - - UTC = ZoneInfo("UTC") - EASTERN = ZoneInfo("America/New_York") - - def _call(self, raw: dict[str, str] | None, tz: ZoneInfo | None = None): # type: ignore[return] - return McpCalendarClient._parse_event_dt(raw, tz=tz or self.UTC) - - def test_a_falsy_payload_is_none_before_any_parsing(self) -> None: - assert self._call(None) is None - assert self._call({}) is None - - def test_the_payload_and_tz_reach_event_date_time(self) -> None: - """This copy is delegation only; the branch table is EventDateTime's.""" - from datetime import date as _date - - assert self._call({"dateTime": "2025-03-01T09:00:00Z"}).hour == 9 - assert self._call({"dateTime": "2025-03-01T09:00:00+05:00"}, tz=self.UTC).hour == 4 - assert self._call({"date": "2025-03-01"}).date() == _date(2025, 3, 1) - assert self._call({"date": "2025-06-01"}, tz=self.EASTERN).tzinfo == self.EASTERN - - -# ── load_list() tests ────────────────────────────────────────────────────────── - - -@pytest.mark.asyncio -async def test_load_list_empty_returns_empty_snapshot() -> None: - """load_list with no calendars returns empty snapshot without calling workbench.""" - from fateforger.core.calendar_preferences import CalendarEntry, CalendarList - - workbench = _FakeWorkbench(payload={"events": [], "totalCount": 0}) - client = McpCalendarClient.__new__(McpCalendarClient) - client._workbench = workbench - list_def = CalendarList( - calendars=[], - write_calendar=CalendarEntry(account="work", calendar_id="primary"), - ) - snapshot = await client.load_list( - list_def=list_def, day=date(2026, 4, 1), tz=ZoneInfo("Europe/Amsterdam") - ) - assert snapshot.response.events == [] - assert snapshot.immovables == [] - assert workbench.last_arguments is None # workbench was never called - - -@pytest.mark.asyncio -async def test_load_list_single_calendar_passes_id_as_string() -> None: - """Single calendar should pass calendarId as a plain string, not a JSON array.""" - from fateforger.core.calendar_preferences import CalendarEntry, CalendarList - - workbench = _FakeWorkbench(payload={"events": [], "totalCount": 0}) - client = McpCalendarClient.__new__(McpCalendarClient) - client._workbench = workbench - list_def = CalendarList( - calendars=[CalendarEntry(account="work", calendar_id="work@gmail.com")], - write_calendar=CalendarEntry(account="work", calendar_id="work@gmail.com"), - ) - await client.load_list( - list_def=list_def, day=date(2026, 4, 1), tz=ZoneInfo("Europe/Amsterdam") - ) - assert workbench.last_arguments is not None - assert workbench.last_arguments["calendarId"] == "work@gmail.com" - - -@pytest.mark.asyncio -async def test_load_list_multiple_calendars_passes_json_array() -> None: - """Multiple calendars should pass calendarId as a JSON-encoded array string.""" - from fateforger.core.calendar_preferences import CalendarEntry, CalendarList - - workbench = _FakeWorkbench(payload={"events": [], "totalCount": 0}) - client = McpCalendarClient.__new__(McpCalendarClient) - client._workbench = workbench - list_def = CalendarList( - calendars=[ - CalendarEntry(account="work", calendar_id="work@gmail.com"), - CalendarEntry(account="personal", calendar_id="primary"), - ], - write_calendar=CalendarEntry(account="work", calendar_id="work@gmail.com"), - ) - await client.load_list( - list_def=list_def, day=date(2026, 4, 1), tz=ZoneInfo("Europe/Amsterdam") - ) - assert workbench.last_arguments is not None - ids = json.loads(workbench.last_arguments["calendarId"]) - assert ids == ["work@gmail.com", "primary"] - - -@pytest.mark.asyncio -async def test_load_list_deduplicates_by_event_id() -> None: - """Events with duplicate IDs should be deduplicated; first occurrence wins.""" - from fateforger.core.calendar_preferences import CalendarEntry, CalendarList - - events = [ - { - "id": "evt-1", "summary": "First copy", - "start": {"dateTime": "2026-04-01T09:00:00"}, - "end": {"dateTime": "2026-04-01T10:00:00"}, - }, - { - "id": "evt-1", "summary": "Duplicate", - "start": {"dateTime": "2026-04-01T09:00:00"}, - "end": {"dateTime": "2026-04-01T10:00:00"}, - }, - { - "id": "evt-2", "summary": "Unique", - "start": {"dateTime": "2026-04-01T11:00:00"}, - "end": {"dateTime": "2026-04-01T12:00:00"}, - }, - ] - workbench = _FakeWorkbench(payload={"events": events, "totalCount": len(events)}) - client = McpCalendarClient.__new__(McpCalendarClient) - client._workbench = workbench - list_def = CalendarList( - calendars=[ - CalendarEntry(account="work", calendar_id="primary"), - CalendarEntry(account="personal", calendar_id="other@gmail.com"), - ], - write_calendar=CalendarEntry(account="work", calendar_id="primary"), - ) - snapshot = await client.load_list( - list_def=list_def, day=date(2026, 4, 1), tz=ZoneInfo("Europe/Amsterdam") - ) - event_ids = [e.id for e in snapshot.response.events] - assert event_ids.count("evt-1") == 1 - summaries = [e.summary for e in snapshot.response.events if e.id == "evt-1"] - assert summaries == ["First copy"] - assert len(snapshot.response.events) == 2 # evt-1 (deduplicated) + evt-2 diff --git a/tests/unit/timeboxing/test_timeboxing_memory_backend_selection.py b/tests/unit/timeboxing/test_timeboxing_memory_backend_selection.py deleted file mode 100644 index 18cdb6c9..00000000 --- a/tests/unit/timeboxing/test_timeboxing_memory_backend_selection.py +++ /dev/null @@ -1,150 +0,0 @@ -from __future__ import annotations - -import asyncio -from types import SimpleNamespace - -import fateforger.agents.timeboxing.agent as timeboxing_agent_mod -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.stage_gating import TimeboxingStage - - -def test_ensure_constraint_memory_client_uses_graphiti_backend(monkeypatch) -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._constraint_memory_client = None - agent._constraint_memory_unavailable_reason = None - - sentinel = object() - captured: dict[str, str] = {} - - def _fake_build(*, user_id: str): - captured["user_id"] = user_id - return sentinel - - monkeypatch.setattr( - timeboxing_agent_mod.settings, "graphiti_user_id", "user-123", raising=False - ) - monkeypatch.setattr( - timeboxing_agent_mod.settings, - "timeboxing_memory_backend", - "graphiti", - raising=False, - ) - monkeypatch.setattr( - timeboxing_agent_mod, "build_graphiti_client_from_settings", _fake_build - ) - - client = TimeboxingFlowAgent._ensure_constraint_memory_client(agent) - - assert client is sentinel - assert captured["user_id"] == "user-123" - assert agent._constraint_memory_client is sentinel - - -def test_ensure_constraint_memory_client_stops_retrying_after_failure(monkeypatch) -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._constraint_memory_client = None - agent._constraint_memory_unavailable_reason = None - - calls = {"count": 0} - - def _boom(*, user_id: str): - _ = user_id - calls["count"] += 1 - raise RuntimeError("graphiti unavailable") - - monkeypatch.setattr( - timeboxing_agent_mod.settings, "graphiti_user_id", "user-123", raising=False - ) - monkeypatch.setattr( - timeboxing_agent_mod.settings, - "timeboxing_memory_backend", - "graphiti", - raising=False, - ) - monkeypatch.setattr( - timeboxing_agent_mod, "build_graphiti_client_from_settings", _boom - ) - - first = TimeboxingFlowAgent._ensure_constraint_memory_client(agent) - second = TimeboxingFlowAgent._ensure_constraint_memory_client(agent) - - assert first is None - assert second is None - assert calls["count"] == 1 - assert "RuntimeError" in (agent._constraint_memory_unavailable_reason or "") - - -def test_ensure_constraint_memory_client_uses_constraint_mcp_backend( - monkeypatch, -) -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._constraint_memory_client = None - agent._constraint_memory_unavailable_reason = None - - sentinel = object() - captured: dict[str, float] = {} - - monkeypatch.setattr( - timeboxing_agent_mod.settings, - "timeboxing_memory_backend", - "constraint_mcp", - raising=False, - ) - monkeypatch.setattr( - timeboxing_agent_mod.settings, - "agent_mcp_discovery_timeout_seconds", - 12, - raising=False, - ) - def _fake_constraint_client(*, timeout: float): - captured["timeout"] = timeout - return sentinel - - monkeypatch.setattr(timeboxing_agent_mod, "ConstraintMemoryClient", _fake_constraint_client) - - client = TimeboxingFlowAgent._ensure_constraint_memory_client(agent) - - assert client is sentinel - assert captured["timeout"] == 12.0 - - -def test_queue_durable_constraint_upsert_queues_without_parent_config( - monkeypatch, -) -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._durable_constraint_task_keys = set() - agent._durable_constraint_semaphore = asyncio.Semaphore(1) - agent._durable_constraint_prefetch_tasks = {} - agent._durable_constraint_prefetch_semaphore = asyncio.Semaphore(1) - agent._append_background_update_once = lambda *_args, **_kwargs: None - agent._reset_durable_prefetch_state = lambda *_args, **_kwargs: None - agent._queue_durable_constraint_prefetch = lambda *_args, **_kwargs: None - - session = Session( - thread_ts="thread", - channel_id="channel", - user_id="user", - planned_date="2026-02-13", - stage=TimeboxingStage.REFINE, - tz_name="UTC", - ) - - called = {"value": False} - - def _fake_create_task(coro): - called["value"] = True - coro.close() - return SimpleNamespace() - - monkeypatch.setattr(timeboxing_agent_mod.asyncio, "create_task", _fake_create_task) - - TimeboxingFlowAgent._queue_durable_constraint_upsert( - agent, - session=session, - text="I prefer deep work before noon.", - reason="test", - decision_scope="profile", - constraints=[], - ) - - assert called["value"] is True diff --git a/tests/unit/timeboxing/test_timeboxing_nlu.py b/tests/unit/timeboxing/test_timeboxing_nlu.py deleted file mode 100644 index da8d8f4f..00000000 --- a/tests/unit/timeboxing/test_timeboxing_nlu.py +++ /dev/null @@ -1,36 +0,0 @@ -from __future__ import annotations - -import pytest - -from fateforger.agents.timeboxing import nlu - - -def test_constraint_interpreter_uses_schema_prompt_text_mode(monkeypatch) -> None: - captured: dict[str, object] = {} - - class _FakeAssistantAgent: - def __init__(self, **kwargs): - captured.update(kwargs) - - monkeypatch.setattr(nlu, "AssistantAgent", _FakeAssistantAgent) - - nlu.build_constraint_interpreter(model_client=object()) - - assert captured.get("output_content_type") is None - system_message = str(captured.get("system_message") or "") - assert "ConstraintInterpretation JSON Schema" in system_message - assert "\"additionalProperties\"" in system_message - - -def test_planned_date_interpreter_keeps_structured_output(monkeypatch) -> None: - captured: dict[str, object] = {} - - class _FakeAssistantAgent: - def __init__(self, **kwargs): - captured.update(kwargs) - - monkeypatch.setattr(nlu, "AssistantAgent", _FakeAssistantAgent) - - nlu.build_planned_date_interpreter(model_client=object()) - - assert captured.get("output_content_type") is nlu.PlannedDateResult diff --git a/tests/unit/timeboxing/test_timeboxing_planning_date_timeout.py b/tests/unit/timeboxing/test_timeboxing_planning_date_timeout.py deleted file mode 100644 index 9cd8a7a0..00000000 --- a/tests/unit/timeboxing/test_timeboxing_planning_date_timeout.py +++ /dev/null @@ -1,57 +0,0 @@ -from __future__ import annotations - -from datetime import datetime, timezone - -import pytest - -pytest.importorskip("autogen_agentchat") - -import fateforger.agents.timeboxing.agent as timeboxing_agent_mod -from fateforger.agents.timeboxing.agent import TimeboxingFlowAgent -from fateforger.agents.timeboxing.nlu import PlannedDateResult - - -class _FakePlanningDateInterpreter: - async def on_messages(self, messages, cancellation_token): # noqa: ARG002 - return object() - - -@pytest.mark.asyncio -async def test_interpret_planned_date_disables_timeout_dumps( - monkeypatch: pytest.MonkeyPatch, -) -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._model_client = object() - - # Patch the factory so _interpret_planned_date gets our stub instead of a real LLM. - monkeypatch.setattr( - timeboxing_agent_mod, - "build_planned_date_interpreter", - lambda *, model_client: _FakePlanningDateInterpreter(), - ) - - captured: dict[str, object] = {} - - async def _fake_with_timeout(label, awaitable, timeout_s, **kwargs): # noqa: ANN001 - captured["label"] = label - captured["timeout_s"] = timeout_s - captured.update(kwargs) - return await awaitable - - monkeypatch.setattr(timeboxing_agent_mod, "with_timeout", _fake_with_timeout) - monkeypatch.setattr( - timeboxing_agent_mod, - "parse_chat_content", - lambda _model, _response: PlannedDateResult(planned_date="2026-02-27"), - ) - - out = await TimeboxingFlowAgent._interpret_planned_date( - agent, - "today", - now=datetime(2026, 2, 27, 1, 0, tzinfo=timezone.utc), - tz_name="Europe/Amsterdam", - ) - assert out == "2026-02-27" - assert captured["label"] == "timeboxing:planning-date" - assert captured["dump_on_timeout"] is False - assert captured["dump_threads_on_timeout"] is False diff --git a/tests/unit/timeboxing/test_timeboxing_prompt_rendering.py b/tests/unit/timeboxing/test_timeboxing_prompt_rendering.py deleted file mode 100644 index 2dc8eb29..00000000 --- a/tests/unit/timeboxing/test_timeboxing_prompt_rendering.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Tests for timeboxing prompt rendering and isolation.""" - -from __future__ import annotations - -from datetime import date - -from typing import Any - -import pytest - -pytest.importorskip("autogen_agentchat") - -from fateforger.agents.timeboxing.contracts import Immovable, SkeletonContext -from fateforger.agents.timeboxing.planning_policy import ( - PLANNING_POLICY_VERSION, - SHARED_PLANNING_POLICY_PROMPT, -) -from fateforger.agents.timeboxing.prompt_rendering import render_skeleton_draft_system_prompt -from fateforger.agents.timeboxing.preferences import ( - Constraint, - ConstraintNecessity, - ConstraintSource, - ConstraintStatus, -) - - -def test_skeleton_prompt_is_single_purpose() -> None: - """Skeleton prompt should be short and not contain cross-stage instructions.""" - context = SkeletonContext( - date=date(2026, 1, 21), - timezone="Europe/Amsterdam", - constraints_snapshot=[ - Constraint( - name="No meetings before 10", - description="User does not do meetings before 10:00.", - necessity=ConstraintNecessity.MUST, - user_id="U1", - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - ) - ], - immovables=[Immovable(title="Gym", start="18:00", end="19:30")], - ) - prompt = render_skeleton_draft_system_prompt(context=context) - - assert "Timeboxing Skeleton Overview Drafter" in prompt - assert "Do not ask questions" in prompt - assert "Data (TOON format):" in prompt - assert "constraints[" in prompt - assert "immovables[" in prompt - assert "frame[1]{date,timezone" in prompt - assert PLANNING_POLICY_VERSION in prompt - assert SHARED_PLANNING_POLICY_PROMPT.splitlines()[0] in prompt - assert "Stage 3 output policy:" in prompt - assert "one bullet line per major block" in prompt - - # Avoid leaking generic multi-stage instructions into the skeleton drafter. - forbidden = [ - "Stage: CollectConstraints", - "Stage: CaptureInputs", - "tool", - "ticktick", - "notion", - ] - lowered = prompt.lower() - for term in forbidden: - assert term.lower() not in lowered diff --git a/tests/unit/timeboxing/test_timeboxing_pydantic_parsing.py b/tests/unit/timeboxing/test_timeboxing_pydantic_parsing.py deleted file mode 100644 index f5a4d4ff..00000000 --- a/tests/unit/timeboxing/test_timeboxing_pydantic_parsing.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace - -from fateforger.agents.timeboxing.nlu import ConstraintInterpretation -from fateforger.agents.timeboxing.pydantic_parsing import parse_chat_content -from fateforger.agents.timeboxing.stage_gating import StageGateOutput - - -def test_parse_chat_content_accepts_json_string_payload() -> None: - response = SimpleNamespace( - chat_message=SimpleNamespace( - content=( - '{"stage_id":"CollectConstraints","ready":false,' - '"summary":["Anchored sleep"],"missing":["work window"],' - '"question":"What work window should we use?",' - '"facts":{"timezone":"Europe/Amsterdam"},' - '"response_message":{"sections":[{"kind":"next_steps",' - '"heading":"What I need from you","content":["Share work window"]}]}}' - ) - ) - ) - - parsed = parse_chat_content(StageGateOutput, response) - assert parsed.stage_id.value == "CollectConstraints" - assert parsed.ready is False - assert parsed.response_message is not None - assert parsed.response_message.sections[0].kind == "next_steps" - - -def test_parse_chat_content_accepts_fenced_json_payload() -> None: - response = SimpleNamespace( - chat_message=SimpleNamespace( - content=( - "```json\n" - '{"stage_id":"CaptureInputs","ready":true,"summary":["Ready"],' - '"missing":[],"question":null,"facts":{"daily_one_thing":"Taxes"},' - '"response_message":{"sections":[]}}\n' - "```" - ) - ) - ) - - parsed = parse_chat_content(StageGateOutput, response) - assert parsed.stage_id.value == "CaptureInputs" - assert parsed.ready is True - - -def test_parse_chat_content_accepts_json_wrapped_in_text_prefix() -> None: - response = SimpleNamespace( - chat_message=SimpleNamespace( - content=( - "Here is the structured output:\\n" - '{"should_extract":true,"scope":"session","constraints":[],' - '"start_date":null,"end_date":null,"language":null,' - '"explanation":"User stated actionable planning constraints."}' - ) - ) - ) - - parsed = parse_chat_content(ConstraintInterpretation, response) - assert parsed.should_extract is True - assert parsed.scope == "session" - assert parsed.constraints == [] - - -def test_parse_chat_content_accepts_double_encoded_json_string() -> None: - response = SimpleNamespace( - chat_message=SimpleNamespace( - content=( - '"{\\"should_extract\\":true,\\"scope\\":\\"session\\",' - '\\"constraints\\":[],\\"start_date\\":null,\\"end_date\\":null,' - '\\"language\\":null,\\"explanation\\":\\"Double encoded\\"}"' - ) - ) - ) - - parsed = parse_chat_content(ConstraintInterpretation, response) - assert parsed.should_extract is True - assert parsed.scope == "session" diff --git a/tests/unit/timeboxing/test_timeboxing_refine_loop_cap.py b/tests/unit/timeboxing/test_timeboxing_refine_loop_cap.py deleted file mode 100644 index ec858da3..00000000 --- a/tests/unit/timeboxing/test_timeboxing_refine_loop_cap.py +++ /dev/null @@ -1,220 +0,0 @@ -"""Stage 4 must advance or fail, never re-render the same day forever. - -A real session ran Refine nine times. Each pass produced no patch, so the -constraint list was the only thing left to render, and each pass appended -another copy until the message crossed Slack's size limit and `chat.update` -began refusing with `msg_too_long`. The progress channel and the error channel -were the same message, so the session went silent with no way to say why β€” -twelve minutes indistinguishable from working. -""" - -from __future__ import annotations - -import pytest - -from fateforger.agents.timeboxing.agent import ( - _REFINE_NO_CHANGE_LIMIT, - RefineMadeNoProgress, - Session, -) - - -def _session() -> Session: - return Session(thread_ts="1787398114.671739", channel_id="C0AA6HC1RJL", user_id="U") - - -def test_a_fresh_session_has_not_looped(): - assert _session().consecutive_refine_no_change == 0 - - -def test_the_limit_allows_an_ordinary_no_op_pass(): - """One no-change pass is normal. - - A day that already satisfies its constraints legitimately needs no patch, - and a user's no-op instruction plausibly produces a second. The cap has to - sit above both or it fires on correct behaviour. - """ - assert _REFINE_NO_CHANGE_LIMIT > 2 - - -def test_the_limit_sits_below_what_a_real_session_did(): - """The observed loop was nine passes. A cap above that would not have fired.""" - assert _REFINE_NO_CHANGE_LIMIT < 9 - - -@pytest.fixture -def refine_agent(): - """A real agent object with only what Stage 4 touches populated. - - Constructed without __init__ deliberately: the real one builds MCP clients - and model clients, none of which this path uses, and a test that needs the - world running tests the world. - """ - from unittest.mock import AsyncMock - - from fateforger.agents.timeboxing.agent import TimeboxingFlowAgent - from fateforger.agents.timeboxing.tb_models import TBPlan - - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._session_debug = lambda *a, **k: None - agent._collect_constraints = AsyncMock(return_value=[]) - agent._select_constraints_for_refine_patcher = lambda **kw: [] - - session = Session(thread_ts="1787398114.671739", channel_id="C0AA6HC1RJL", user_id="U") - session.tb_plan = TBPlan(date="2026-08-24", tz="Europe/Amsterdam", events=[]) - session.timebox = None - return agent, session - - -async def _drive_refine(agent, session, *, plan_changes: bool): - """Run one real Stage 4 pass, stubbing only the patcher itself.""" - from unittest.mock import AsyncMock - - from fateforger.agents.timeboxing import agent as agent_mod - - returned = session.tb_plan.model_copy(deep=True) - if plan_changes: - returned.tz = "UTC" - - async def _apply_patch(*, plan_validator, **kw): - # The real patcher calls the validator, which is what populates - # validated_timebox. A stub that skips it makes the method raise before - # reaching the counter logic under test. - plan_validator(returned) - return returned, None - - agent._timebox_patcher = type("P", (), {"apply_patch": staticmethod(_apply_patch)})() - - original = agent_mod.tb_plan_to_timebox - # Must survive a second pass: the method deep-copies session.timebox at - # the top, so a bare object() breaks on the loop this test is about. - class _Timebox: - def model_copy(self, deep: bool = False): - return self - - agent_mod.tb_plan_to_timebox = lambda _p: _Timebox() - try: - return await agent._execute_refine_patch_and_sync( - session=session, patch_message="tighten the morning" - ) - finally: - agent_mod.tb_plan_to_timebox = original - - -async def test_a_plan_change_clears_the_counter_in_the_real_pass(refine_agent): - """Only *consecutive* no-change passes indicate a loop. - - Previously this set the counter itself and asserted on its own - assignment β€” it passed with the reset deleted from agent.py. Without that - reset, no-op passes accumulate across a whole session and the cap fires on - a day that legitimately needed three separate no-op patches. - """ - agent, session = refine_agent - session.consecutive_refine_no_change = 2 - await _drive_refine(agent, session, plan_changes=True) - assert session.consecutive_refine_no_change == 0 - - -async def test_consecutive_no_change_passes_accumulate_then_raise(refine_agent): - agent, session = refine_agent - for _ in range(_REFINE_NO_CHANGE_LIMIT - 1): - await _drive_refine(agent, session, plan_changes=False) - assert session.consecutive_refine_no_change == _REFINE_NO_CHANGE_LIMIT - 1 - - with pytest.raises(RefineMadeNoProgress): - await _drive_refine(agent, session, plan_changes=False) - - -async def test_the_raised_error_carries_counts_from_the_pass_itself(refine_agent): - """These numbers are the only probe into the unidentified root cause. - - Previously this built the f-string in the test body, so it asserted the - test's own formatting β€” it passed with the real message replaced by the - literal "Refine looped." - """ - agent, session = refine_agent - session.consecutive_refine_no_change = _REFINE_NO_CHANGE_LIMIT - 1 - agent._select_constraints_for_refine_patcher = lambda **kw: [object()] * 7 - - from unittest.mock import AsyncMock - - agent._collect_constraints = AsyncMock(return_value=[object()] * 19) - - with pytest.raises(RefineMadeNoProgress) as caught: - await _drive_refine(agent, session, plan_changes=False) - - text = str(caught.value) - assert "19 constraints" in text, text - assert "selected 7" in text, text - - -def test_it_is_an_exception_so_it_reaches_the_user(): - """Returned quietly it would be another thing only a log file knows.""" - assert issubclass(RefineMadeNoProgress, Exception) - with pytest.raises(RefineMadeNoProgress): - raise RefineMadeNoProgress("looped") - - -# --- the constraints that do not fit ------------------------------------- - - -def test_a_short_list_reports_nothing_dropped(refine_agent): - agent, session = refine_agent - from fateforger.agents.timeboxing.agent import ( - TIMEBOXING_LIMITS, - TimeboxingFlowAgent, - ) - - limit = TIMEBOXING_LIMITS.refine_patcher_constraint_limit - # The real method, not the fixture's stub β€” which is what the loop tests - # replace it with and would silently make this assert nothing. - kept = TimeboxingFlowAgent._select_constraints_for_refine_patcher( - agent, session=session, constraints=[_c("must", i) for i in range(limit - 2)] - ) - assert len(kept) == limit - 2 - assert session.last_refine_dropped_constraints_count == 0 - - -def test_constraints_that_do_not_fit_are_counted(refine_agent): - """Measured on the real store: 18 MUST and 12 SHOULD against a limit of 24 - means six of the user's preferences never reach the patcher. The limit is - right; dropping them without a word is not. - """ - agent, session = refine_agent - from fateforger.agents.timeboxing.agent import ( - TIMEBOXING_LIMITS, - TimeboxingFlowAgent, - ) - - limit = TIMEBOXING_LIMITS.refine_patcher_constraint_limit - constraints = [_c("must", i) for i in range(18)] + [ - _c("should", i) for i in range(12) - ] - - kept = TimeboxingFlowAgent._select_constraints_for_refine_patcher( - agent, session=session, constraints=constraints - ) - assert len(kept) == limit - assert session.last_refine_dropped_constraints_count == len(constraints) - limit - - -def _c(necessity: str, index: int = 0): - """A real Constraint, not a stub. - - The selector reads a dozen fields across ranking and identity; a - hand-rolled stand-in fails on whichever one it forgot, and passing that - way would only mean the test stopped short of the code under test. - """ - from datetime import datetime, timezone - - from fateforger.agents.timeboxing.preferences import Constraint - - now = datetime.now(timezone.utc) - return Constraint( - name=f"{necessity}-rule-{index}", - description=f"a {necessity} rule ({index})", - necessity=necessity, - user_id="U_HUGO", - created_at=now, - updated_at=now, - ) diff --git a/tests/unit/timeboxing/test_timeboxing_refine_renders_schedule.py b/tests/unit/timeboxing/test_timeboxing_refine_renders_schedule.py deleted file mode 100644 index 67ab3716..00000000 --- a/tests/unit/timeboxing/test_timeboxing_refine_renders_schedule.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Stage 4 must show the user the schedule it just changed. - -Reconstructed from a real session (2026-03-08, thread 1772956522.019509). -The patcher succeeded on all five Refine passes and the plan grew from 6 to 13 -events, yet every rendered message carried only a prose paragraph. The first -message was 393 characters -- complete, not truncated -- and read: - - ### Draft Overview - The draft accommodates your early F1 race, a morning deep work block, ... - ### What I need from you - Review the refined schedule above. - -There was no schedule above. Twenty-four seconds later the user replied -"Commit, but you don't show the schedule", and eight minutes later, after the -patcher had added a deep work block and an evening routine they had asked for, -"You didn't add anything." - -The plan was changing. Only the rendering never said so. -""" - -from __future__ import annotations - -from datetime import date, time, timedelta - -import pytest - -pytest.importorskip("autogen_agentchat") - -from fateforger.agents.timeboxing.agent import TimeboxingFlowAgent -from fateforger.agents.timeboxing.stage_gating import ( - FreeformSection, - SessionMessage, - StageGateOutput, - TimeboxingStage, -) -from fateforger.agents.timeboxing.timebox import Timebox -from fateforger.agents.schedular.models.calendar import CalendarEvent, EventType - - -def _prose_only_gate() -> StageGateOutput: - """The gate as Stage 4 actually produced it: overview prose, no schedule.""" - return StageGateOutput( - stage_id=TimeboxingStage.REFINE, - ready=True, - summary=[], - missing=[], - question="Review the refined schedule above.", - facts={}, - response_message=SessionMessage( - sections=[ - FreeformSection( - heading="Draft Overview", - content=( - "The draft accommodates your early F1 race, a morning " - "deep work block, and perfectly buffers your afternoon " - "hockey game with commute times." - ), - ), - ] - ), - ) - - -def _timebox() -> Timebox: - """Two blocks the user asked for and the patcher really did add.""" - return Timebox( - events=[ - CalendarEvent( - summary="Deep Work: Secondary Lane", - event_type=EventType.DEEP_WORK, - start_time=time(16, 30), - duration=timedelta(hours=1, minutes=30), - ), - CalendarEvent( - summary="Evening Wind-Down", - event_type=EventType.REGENERATION, - start_time=time(21, 0), - duration=timedelta(hours=2), - ), - ], - date=date(2026, 3, 8), - timezone="Europe/Amsterdam", - ) - - -def test_refine_message_names_the_blocks_in_the_plan() -> None: - """A Refine message that cannot name the user's blocks has not shown them.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - - message = agent._format_stage_message( - _prose_only_gate(), - constraints=[], - immovables=[], - timebox=_timebox(), - ) - - assert "Deep Work: Secondary Lane" in message - assert "Evening Wind-Down" in message - - -def test_refine_message_gives_each_block_a_time() -> None: - """Names alone are not a schedule -- the user asked when, twice.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - - message = agent._format_stage_message( - _prose_only_gate(), - constraints=[], - immovables=[], - timebox=_timebox(), - ) - - assert "16:30" in message - assert "21:00" in message - - -def test_the_plan_is_rendered_even_when_the_model_wrote_its_own_schedule() -> None: - """The render is unconditional, not "only if the model forgot". - - The two tests above use a prose-only gate, so they prove that a gate with - no schedule gets one. They do not prove the invariant, and the difference - is not academic: this mutation passes both of them -- - - _already = any("schedule" in (sec.heading or "").lower() - for sec in gate.response_message.sections) - schedule_lines = [] if _already else self._format_schedule_lines(timebox) - - "Don't duplicate what the model already wrote" is a plausible, - well-intentioned refactor. It also reinstates exactly the failure this file - exists for, because the incident *was* a stage model writing something that - read like a schedule. Whether its prose really showed the plan is a - judgement about generated text, and it was wrong five times out of five. - - So: a gate that already carries a "Schedule" section, holding a stale plan - that shares not one block with the real one. The authoritative render comes - from `session.timebox` regardless, and the blocks that actually exist are - the ones the user sees. - - Found by admonish-1-c5, who mutated the invariant rather than the renderer. - """ - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - gate = StageGateOutput( - stage_id=TimeboxingStage.REFINE, - ready=True, - summary=[], - missing=[], - question="Review the refined schedule above.", - facts={}, - response_message=SessionMessage( - sections=[ - FreeformSection( - heading="Schedule", - content="- 09:00-10:00 Yesterday's Standup\n- 10:00-11:00 Inbox", - ), - ] - ), - ) - - message = agent._format_stage_message( - gate, constraints=[], immovables=[], timebox=_timebox() - ) - - assert "Deep Work: Secondary Lane" in message - assert "Evening Wind-Down" in message - assert "16:30" in message diff --git a/tests/unit/timeboxing/test_timeboxing_refine_tool_orchestration.py b/tests/unit/timeboxing/test_timeboxing_refine_tool_orchestration.py deleted file mode 100644 index 2a732a94..00000000 --- a/tests/unit/timeboxing/test_timeboxing_refine_tool_orchestration.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -import pytest - -pytest.importorskip("autogen_agentchat") - -from fateforger.agents.timeboxing.agent import TimeboxingFlowAgent -from fateforger.agents.timeboxing.sync_engine import SyncOp, SyncOpType, SyncTransaction - - -def test_select_refine_tool_intents_prioritizes_patch() -> None: - """Patch-critical intent should be selected ahead of memory intents.""" - patch, memory = TimeboxingFlowAgent._select_refine_tool_intents( - [ - (10, "memory", "remember this preference"), - (0, "patch", "add lunch and buffer"), - (0, "patch", "ignored second patch"), - ] - ) - assert patch == "add lunch and buffer" - assert memory == "remember this preference" - - -def test_summarize_sync_transaction_reports_unchanged_when_no_ops() -> None: - """Empty sync transactions should report unchanged calendar state.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - tx = SyncTransaction(status="committed") - outcome = TimeboxingFlowAgent._summarize_sync_transaction(agent, tx) - assert outcome.changed is False - assert outcome.created == 0 - assert outcome.updated == 0 - assert outcome.deleted == 0 - assert outcome.failed == 0 - assert "unchanged" in outcome.note.lower() - - -def test_summarize_sync_transaction_reports_partial_counts() -> None: - """Partial sync should include per-op success/failure counters.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - tx = SyncTransaction( - ops=[ - SyncOp( - op_type=SyncOpType.CREATE, - gcal_event_id="fftb1", - after_payload={}, - ), - SyncOp( - op_type=SyncOpType.UPDATE, - gcal_event_id="fftb2", - after_payload={}, - ), - ], - results=[{"ok": True}, {"ok": False}], - status="partial", - ) - outcome = TimeboxingFlowAgent._summarize_sync_transaction(agent, tx) - assert outcome.status == "partial" - assert outcome.changed is True - assert outcome.created == 1 - assert outcome.updated == 0 - assert outcome.deleted == 0 - assert outcome.failed == 1 - assert "partially changed" in outcome.note.lower() diff --git a/tests/unit/timeboxing/test_timeboxing_remote_snapshot_plan.py b/tests/unit/timeboxing/test_timeboxing_remote_snapshot_plan.py deleted file mode 100644 index 63b93bec..00000000 --- a/tests/unit/timeboxing/test_timeboxing_remote_snapshot_plan.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Regression tests for Stage 3 remote snapshot plan building.""" - -from __future__ import annotations - -from datetime import date - -import pytest - -pytest.importorskip("autogen_agentchat") - -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.tb_models import TBEvent, TBPlan, FixedWindow -from fateforger.agents.timeboxing.timebox import Timebox - - -def test_build_remote_snapshot_plan_bypasses_timebox_validators( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Stage 3 baseline snapshot should not depend on Timebox validator execution.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-02-14", - tz_name="Europe/Amsterdam", - frame_facts={ - "immovables": [ - { - "summary": "Brunch", - "event_type": "M", - "start_time": "11:30", - "end_time": "13:00", - "calendarId": "primary", - "timeZone": "Europe/Amsterdam", - } - ] - }, - ) - - def _boom(self: Timebox) -> Timebox: - _ = self - raise RuntimeError("validator should not run for remote snapshot baseline") - - monkeypatch.setattr(Timebox, "schedule_and_validate", _boom) - - plan = TimeboxingFlowAgent._build_remote_snapshot_plan(agent, session) - - assert plan.date == date(2026, 2, 14) - assert len(plan.events) == 1 - assert plan.events[0].n == "Brunch" - - -def test_build_remote_snapshot_plan_uses_prefetched_identity() -> None: - """Refine baseline should hydrate event-id mapping from prefetched remote snapshot.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - prefetched_plan = TBPlan( - date=date(2026, 2, 14), - tz="Europe/Amsterdam", - events=[ - TBEvent( - n="Standup", - t="M", - p=FixedWindow(st="09:00", et="09:30"), - ) - ], - ) - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-02-14", - tz_name="Europe/Amsterdam", - prefetched_remote_snapshots_by_date={"2026-02-14": prefetched_plan}, - prefetched_event_id_maps_by_date={"2026-02-14": {"Standup|09:00:00": "fftb-1"}}, - prefetched_remote_event_ids_by_date={"2026-02-14": ["fftb-1"]}, - ) - - plan = TimeboxingFlowAgent._build_remote_snapshot_plan(agent, session) - - assert plan.events[0].n == "Standup" - assert session.event_id_map["Standup|09:00:00"] == "fftb-1" - assert session.remote_event_ids_by_index == ["fftb-1"] - - -def test_build_remote_snapshot_plan_maps_immovable_shape() -> None: - """`title/start/end` immovable rows should become snapshot TB events.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-02-14", - tz_name="Europe/Amsterdam", - frame_facts={ - "immovables": [ - { - "title": "Lunch", - "start": "13:00", - "end": "14:00", - } - ] - }, - ) - - plan = TimeboxingFlowAgent._build_remote_snapshot_plan(agent, session) - - assert len(plan.events) == 1 - assert plan.events[0].n == "Lunch" diff --git a/tests/unit/timeboxing/test_timeboxing_review_submit_prompt.py b/tests/unit/timeboxing/test_timeboxing_review_submit_prompt.py deleted file mode 100644 index 00a634c8..00000000 --- a/tests/unit/timeboxing/test_timeboxing_review_submit_prompt.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Unit tests for Stage 2 pre-gen trigger and Stage 5 submit prompt behavior.""" - -from __future__ import annotations - -import types -from datetime import date, time, timedelta -from unittest.mock import AsyncMock - -import pytest - -pytest.importorskip("autogen_agentchat") - -from autogen_agentchat.messages import TextMessage -from autogen_core import CancellationToken - -from fateforger.agents.schedular.models.calendar import CalendarEvent, EventType -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.nodes.nodes import ( - StageCaptureInputsNode, - StageReviewCommitNode, - TransitionNode, -) -from fateforger.agents.timeboxing.stage_gating import StageGateOutput, TimeboxingStage -from fateforger.agents.timeboxing.timebox import Timebox - - -def _build_transition() -> TransitionNode: - """Return a minimal transition node stub for stage node tests.""" - transition = TransitionNode.__new__(TransitionNode) - transition.stage_user_message = "test" - transition.decision = None - return transition - - -@pytest.mark.asyncio -async def test_stage_capture_inputs_queues_skeleton_pre_generation() -> None: - """Stage 2 should trigger background skeleton pre-generation hook.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._queue_skeleton_pre_generation = AsyncMock() - await_pending_mock = AsyncMock() - agent._await_pending_constraint_extractions = types.MethodType( # type: ignore[attr-defined] - lambda self, session: await_pending_mock(session), - agent, - ) - - async def _run_stage_gate(*, stage, user_message, context) -> StageGateOutput: - return StageGateOutput( - stage_id=stage, - ready=True, - summary=["ok"], - missing=[], - question=None, - facts={"block_plan": {"deep_blocks": 2}}, - ) - - def _build_context(_session, *, user_message): - return {"user_message": user_message} - - agent._run_stage_gate = types.MethodType( # type: ignore[attr-defined] - lambda self, **kwargs: _run_stage_gate(**kwargs), - agent, - ) - agent._build_capture_inputs_context = types.MethodType( # type: ignore[attr-defined] - lambda self, session, user_message: _build_context(session, user_message=user_message), - agent, - ) - agent._queue_skeleton_pre_generation = types.MethodType( # type: ignore[attr-defined] - lambda self, session: None, - agent, - ) - - called = {"value": False} - - def _queue(_session: Session) -> None: - called["value"] = True - - agent._queue_skeleton_pre_generation = types.MethodType( # type: ignore[attr-defined] - lambda self, session: _queue(session), - agent, - ) - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - node = StageCaptureInputsNode( - orchestrator=agent, - session=session, - transition=_build_transition(), - ) - await node.on_messages( - [TextMessage(content="test", source="user")], - CancellationToken(), - ) - - assert called["value"] is True - await_pending_mock.assert_awaited_once_with(session) - - -@pytest.mark.asyncio -async def test_stage_review_enables_submit_prompt() -> None: - """Stage 5 should immediately enable pending_submit state to auto-commit.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - - submitter = AsyncMock() - agent._calendar_submitter = types.SimpleNamespace(submit_plan=submitter) - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.timebox = Timebox( - events=[ - CalendarEvent( - summary="Focus", - event_type=EventType.DEEP_WORK, - start_time=time(9, 0), - duration=timedelta(minutes=90), - ) - ], - date=date(2026, 2, 13), - timezone="Europe/Amsterdam", - ) - - node = StageReviewCommitNode( - orchestrator=agent, - session=session, - transition=_build_transition(), - ) - await node.on_messages( - [TextMessage(content="proceed", source="user")], - CancellationToken(), - ) - - assert session.pending_submit is True - # The actual submission happens later in the flow orchestration (agent.py) - submitter.assert_not_called() diff --git a/tests/unit/timeboxing/test_timeboxing_scheduler_prefetch_capability.py b/tests/unit/timeboxing/test_timeboxing_scheduler_prefetch_capability.py deleted file mode 100644 index 30168621..00000000 --- a/tests/unit/timeboxing/test_timeboxing_scheduler_prefetch_capability.py +++ /dev/null @@ -1,142 +0,0 @@ -from __future__ import annotations - -import asyncio - -import pytest - -from fateforger.agents.timeboxing.scheduler_prefetch_capability import ( - SchedulerPrefetchCapability, -) -from fateforger.agents.timeboxing.stage_gating import TimeboxingStage - - -class _Session: - def __init__( - self, - *, - stage: TimeboxingStage = TimeboxingStage.COLLECT_CONSTRAINTS, - planned_date: str = "2026-02-27", - ): - self.stage = stage - self.planned_date = planned_date - - -@pytest.mark.asyncio -async def test_prime_committed_collect_context_non_blocking_prefetches_in_background() -> None: - calls: list[str] = [] - gate = asyncio.Event() - - async def _await_prefetch(session, *, stage, **_kwargs): # noqa: ARG001 - calls.append(f"await:{stage.value}") - - async def _ensure_calendar(session): # noqa: ARG001 - calls.append("ensure_calendar") - - async def _prefetch_calendar(session, planned_date): # noqa: ARG001 - calls.append(f"prefetch:{planned_date}") - gate.set() - - def _queue_constraint(session): # noqa: ARG001 - calls.append("queue_constraint") - - capability = SchedulerPrefetchCapability( - queue_constraint_prefetch=_queue_constraint, - await_pending_durable_prefetch=_await_prefetch, - ensure_calendar_immovables=_ensure_calendar, - prefetch_calendar_immovables=_prefetch_calendar, - is_collect_stage_loaded=lambda s: False, - ) - await capability.prime_committed_collect_context(session=_Session(), blocking=False) - await asyncio.wait_for(gate.wait(), timeout=0.2) - assert calls == [ - "queue_constraint", - "prefetch:2026-02-27", - ] - - -@pytest.mark.asyncio -async def test_prime_committed_collect_context_blocking_uses_bounded_calendar_ensure() -> None: - calls: list[str] = [] - - async def _await_prefetch(session, *, stage, **_kwargs): # noqa: ARG001 - calls.append(f"await:{stage.value}") - - async def _ensure_calendar(session, **_kwargs): # noqa: ARG001 - calls.append("ensure_calendar") - - async def _prefetch_calendar(session, planned_date): # noqa: ARG001 - calls.append(f"prefetch:{planned_date}") - - capability = SchedulerPrefetchCapability( - queue_constraint_prefetch=lambda s: calls.append("queue_constraint"), # noqa: ARG005 - await_pending_durable_prefetch=_await_prefetch, - ensure_calendar_immovables=_ensure_calendar, - prefetch_calendar_immovables=_prefetch_calendar, - is_collect_stage_loaded=lambda s: False, - ) - await capability.prime_committed_collect_context(session=_Session(), blocking=True) - - assert calls[0] == "queue_constraint" - assert "await:CollectConstraints" in calls - assert "ensure_calendar" in calls - assert not any(call.startswith("prefetch:") for call in calls) - - -@pytest.mark.asyncio -async def test_ensure_collect_stage_ready_waits_only_when_needed() -> None: - calls: list[str] = [] - - async def _await_prefetch(session, *, stage, **kwargs): # noqa: ARG001 - calls.append(f"{stage.value}:{kwargs.get('fail_on_timeout')}") - - async def _ensure_calendar(session, **_kwargs): # noqa: ARG001 - calls.append("ensure_calendar") - - capability = SchedulerPrefetchCapability( - queue_constraint_prefetch=lambda s: None, - await_pending_durable_prefetch=_await_prefetch, - ensure_calendar_immovables=_ensure_calendar, - prefetch_calendar_immovables=lambda s, d: None, # type: ignore[arg-type] - is_collect_stage_loaded=lambda s: False, - ) - await capability.ensure_collect_stage_ready(session=_Session()) - await capability.ensure_collect_stage_ready( - session=_Session(stage=TimeboxingStage.CAPTURE_INPUTS) - ) - capability_loaded = SchedulerPrefetchCapability( - queue_constraint_prefetch=lambda s: None, - await_pending_durable_prefetch=_await_prefetch, - ensure_calendar_immovables=_ensure_calendar, - prefetch_calendar_immovables=lambda s, d: None, # type: ignore[arg-type] - is_collect_stage_loaded=lambda s: True, - ) - await capability_loaded.ensure_collect_stage_ready(session=_Session()) - assert calls == [ - "ensure_calendar", - "CollectConstraints:False", - "ensure_calendar", - ] - - -@pytest.mark.asyncio -async def test_prime_committed_collect_context_blocking_without_date_uses_ensure() -> None: - calls: list[str] = [] - - async def _await_prefetch(session, *, stage, **_kwargs): # noqa: ARG001 - calls.append(f"await:{stage.value}") - - async def _ensure_calendar(session, **_kwargs): # noqa: ARG001 - calls.append("ensure_calendar") - - capability = SchedulerPrefetchCapability( - queue_constraint_prefetch=lambda s: calls.append("queue_constraint"), # noqa: ARG005 - await_pending_durable_prefetch=_await_prefetch, - ensure_calendar_immovables=_ensure_calendar, - prefetch_calendar_immovables=lambda s, d: None, # type: ignore[arg-type] - is_collect_stage_loaded=lambda s: False, - ) - await capability.prime_committed_collect_context( - session=_Session(planned_date=""), - blocking=True, - ) - assert "ensure_calendar" in calls diff --git a/tests/unit/timeboxing/test_timeboxing_session_init_order.py b/tests/unit/timeboxing/test_timeboxing_session_init_order.py deleted file mode 100644 index 3addd253..00000000 --- a/tests/unit/timeboxing/test_timeboxing_session_init_order.py +++ /dev/null @@ -1,803 +0,0 @@ -"""Regression tests for deterministic session registration before async interpretation.""" - -from __future__ import annotations - -import asyncio -from types import SimpleNamespace - -import pytest -from autogen_agentchat.messages import TextMessage - -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.messages import StartTimeboxing, TimeboxingUserReply -from fateforger.agents.timeboxing.stage_gating import StageDecision, TimeboxingStage - - -def _build_agent() -> TimeboxingFlowAgent: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._sessions = {} - agent._session_key = lambda _ctx, fallback=None: fallback or "default" - agent._default_tz_name = lambda: "UTC" - agent._default_planned_date = lambda *, now, tz: "2026-02-27" - agent._queue_constraint_prefetch = lambda _session: None - agent._reset_durable_prefetch_state = lambda _session: None - agent._session_debug = lambda *_args, **_kwargs: None - agent._build_commit_prompt_blocks = lambda *, session: TextMessage( - content=f"commit:{session.thread_ts}", - source="timeboxing_agent", - ) - agent._refresh_temporal_facts = lambda _session: None - - async def _prefetch_calendar_immovables(_session, _planned_date): - return None - - agent._prefetch_calendar_immovables = _prefetch_calendar_immovables - - async def _prime_collect_prefetch_non_blocking(*, session, planned_date, blocking): # noqa: ARG001 - return None - - async def _run_graph_turn(*, session, user_text): # noqa: ARG001 - return TextMessage(content="graph-progressed", source="timeboxing_agent") - - async def _maybe_wrap_constraint_review(*, reply, session): # noqa: ARG001 - return reply - - async def _publish_update(*, session, user_message, actions): # noqa: ARG001 - return None - - async def _maybe_handle_memory_review_turn(*, session, user_message): # noqa: ARG001 - return None - - agent._prime_collect_prefetch_non_blocking = _prime_collect_prefetch_non_blocking - agent._run_graph_turn = _run_graph_turn - agent._maybe_wrap_constraint_review = _maybe_wrap_constraint_review - agent._maybe_handle_memory_review_turn = _maybe_handle_memory_review_turn - agent._attach_presenter_blocks = lambda *, reply, session: reply - agent._publish_update = _publish_update - - class _SchedulerPrefetch: - def __init__(self) -> None: - self.initial_prefetch_calls = 0 - - def queue_initial_prefetch(self, *, session, planned_date): # noqa: ARG002 - self.initial_prefetch_calls += 1 - return None - - async def ensure_collect_stage_ready(self, *, session): # noqa: ARG002 - return None - - agent._scheduler_prefetch = _SchedulerPrefetch() - return agent - - -def test_session_key_prefers_thread_fallback_over_topic_source() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - ctx = SimpleNamespace( - topic_id=SimpleNamespace(source="topic-key"), - sender=SimpleNamespace(key="sender-key"), - ) - assert agent._session_key(ctx, fallback="thread-1") == "thread-1" - assert agent._session_key(ctx) == "topic-key" - - -@pytest.mark.asyncio -async def test_on_start_registers_session_before_date_interpretation() -> None: - agent = _build_agent() - seen: dict[str, bool] = {} - - async def _interpret(text: str, *, now, tz_name): # noqa: ARG001 - seen["session_present"] = "thread-1" in agent._sessions - await asyncio.sleep(0) - return "2026-03-01" - - agent._interpret_planned_date = _interpret - - out = await TimeboxingFlowAgent.on_start( - agent, - StartTimeboxing( - channel_id="C1", - thread_ts="thread-1", - user_id="U1", - user_input="timebox today", - ), - SimpleNamespace(), - ) - - assert isinstance(out, TextMessage) - assert seen == {"session_present": True} - assert agent._sessions["thread-1"].planned_date == "2026-03-01" - - -@pytest.mark.asyncio -async def test_on_user_reply_registers_session_before_date_interpretation() -> None: - agent = _build_agent() - seen: dict[str, bool] = {} - - async def _interpret(text: str, *, now, tz_name): # noqa: ARG001 - seen["session_present"] = "thread-2" in agent._sessions - await asyncio.sleep(0) - return "2026-03-02" - - agent._interpret_planned_date = _interpret - - out = await TimeboxingFlowAgent.on_user_reply( - agent, - TimeboxingUserReply( - channel_id="C1", - thread_ts="thread-2", - user_id="U1", - text="tomorrow", - ), - SimpleNamespace(), - ) - - assert isinstance(out, TextMessage) - assert out.content == "graph-progressed" - assert seen == {"session_present": True} - assert agent._sessions["thread-2"].planned_date == "2026-03-02" - assert agent._sessions["thread-2"].committed is True - - -@pytest.mark.asyncio -async def test_on_user_reply_in_thread_commits_existing_session_without_button() -> None: - agent = _build_agent() - session = Session( - thread_ts="thread-3", - channel_id="C1", - user_id="U1", - committed=False, - planned_date="2026-02-27", - tz_name="UTC", - session_key="thread-3", - ) - agent._sessions["thread-3"] = session - - async def _interpret(text: str, *, now, tz_name): # noqa: ARG001 - return "2026-02-27" - - async def _prime_collect_prefetch_non_blocking(*, session, planned_date, blocking): # noqa: ARG001 - return None - - async def _run_graph_turn(*, session, user_text): # noqa: ARG001 - return TextMessage(content="graph-progressed", source="timeboxing_agent") - - async def _maybe_wrap_constraint_review(*, reply, session): # noqa: ARG001 - return reply - - async def _publish_update(*, session, user_message, actions): # noqa: ARG001 - return None - - agent._interpret_planned_date = _interpret - agent._prime_collect_prefetch_non_blocking = _prime_collect_prefetch_non_blocking - agent._run_graph_turn = _run_graph_turn - agent._maybe_wrap_constraint_review = _maybe_wrap_constraint_review - agent._attach_presenter_blocks = lambda *, reply, session: reply - agent._publish_update = _publish_update - - ctx = SimpleNamespace(topic_id=SimpleNamespace(source="other-routing-key"), sender=None) - out = await TimeboxingFlowAgent.on_user_reply( - agent, - TimeboxingUserReply( - channel_id="C1", - thread_ts="thread-3", - user_id="U1", - text="Today. Wake 12:00, no fixed plans.", - ), - ctx, - ) - - assert isinstance(out, TextMessage) - assert out.content == "graph-progressed" - assert session.committed is True - - -@pytest.mark.asyncio -async def test_on_user_reply_serializes_implicit_commit_for_rapid_replies() -> None: - agent = _build_agent() - session = Session( - thread_ts="thread-rapid", - channel_id="C1", - user_id="U1", - committed=False, - planned_date="2026-02-27", - tz_name="UTC", - session_key="thread-rapid", - ) - agent._sessions["thread-rapid"] = session - interpret_count = {"count": 0} - - async def _interpret(text: str, *, now, tz_name): # noqa: ARG001 - interpret_count["count"] += 1 - await asyncio.sleep(0.05) - return "2026-02-27" - - async def _run_graph_turn(*, session, user_text): # noqa: ARG001 - await asyncio.sleep(0.01) - return TextMessage(content=f"graph:{user_text}", source="timeboxing_agent") - - agent._interpret_planned_date = _interpret - agent._run_graph_turn = _run_graph_turn - - ctx = SimpleNamespace(topic_id=SimpleNamespace(source="other-routing-key"), sender=None) - first = TimeboxingUserReply( - channel_id="C1", - thread_ts="thread-rapid", - user_id="U1", - text="First fast reply", - ) - second = TimeboxingUserReply( - channel_id="C1", - thread_ts="thread-rapid", - user_id="U1", - text="Second fast reply", - ) - out1, out2 = await asyncio.gather( - TimeboxingFlowAgent.on_user_reply(agent, first, ctx), - TimeboxingFlowAgent.on_user_reply(agent, second, ctx), - ) - - assert isinstance(out1, TextMessage) - assert isinstance(out2, TextMessage) - assert interpret_count["count"] == 1 - assert session.committed is True - - -@pytest.mark.asyncio -async def test_on_user_reply_implicit_commit_does_not_duplicate_prefetch_queue() -> None: - agent = _build_agent() - session = Session( - thread_ts="thread-prefetch", - channel_id="C1", - user_id="U1", - committed=False, - planned_date="2026-02-27", - tz_name="UTC", - session_key="thread-prefetch", - ) - agent._sessions["thread-prefetch"] = session - - async def _interpret(text: str, *, now, tz_name): # noqa: ARG001 - return "2026-02-27" - - async def _run_graph_turn(*, session, user_text): # noqa: ARG001 - return TextMessage(content="graph-progressed", source="timeboxing_agent") - - agent._interpret_planned_date = _interpret - agent._run_graph_turn = _run_graph_turn - - out = await TimeboxingFlowAgent.on_user_reply( - agent, - TimeboxingUserReply( - channel_id="C1", - thread_ts="thread-prefetch", - user_id="U1", - text="Today, continue in this thread.", - ), - SimpleNamespace(), - ) - - assert isinstance(out, TextMessage) - assert out.content == "graph-progressed" - assert agent._scheduler_prefetch.initial_prefetch_calls == 0 - - -@pytest.mark.asyncio -async def test_on_user_reply_review_commit_proceed_submits_without_button() -> None: - agent = _build_agent() - session = Session( - thread_ts="thread-submit", - channel_id="C1", - user_id="U1", - committed=True, - planned_date="2026-02-27", - tz_name="UTC", - session_key="thread-submit", - ) - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = True - agent._sessions["thread-submit"] = session - calls = {"submit": 0, "run_graph": 0} - - async def _decide_next_action(session_obj: Session, *, user_message: str): # noqa: ARG001 - _ = session_obj - return StageDecision(action="proceed", submit_intent=True) - - async def _submit_pending_plan(*, session: Session, submit_mode: str, submit_attempt_kind: str): # noqa: ARG001 - _ = session, submit_mode, submit_attempt_kind - calls["submit"] += 1 - return TextMessage(content="submitted-from-nl", source="timeboxing_agent") - - async def _run_graph_turn(*, session: Session, user_text: str): # noqa: ARG001 - _ = session, user_text - calls["run_graph"] += 1 - return TextMessage(content="graph-progressed", source="timeboxing_agent") - - agent._decide_next_action = _decide_next_action - agent._submit_pending_plan = _submit_pending_plan - agent._run_graph_turn = _run_graph_turn - - out = await TimeboxingFlowAgent.on_user_reply( - agent, - TimeboxingUserReply( - channel_id="C1", - thread_ts="thread-submit", - user_id="U1", - text="Proceed and commit now.", - ), - SimpleNamespace(), - ) - - assert isinstance(out, TextMessage) - assert out.content == "submitted-from-nl" - assert calls["submit"] == 1 - assert calls["run_graph"] == 0 - - -@pytest.mark.asyncio -async def test_on_user_reply_refine_explicit_commit_submits_in_same_turn() -> None: - agent = _build_agent() - session = Session( - thread_ts="thread-refine-submit", - channel_id="C1", - user_id="U1", - committed=True, - planned_date="2026-02-27", - tz_name="UTC", - session_key="thread-refine-submit", - ) - session.stage = TimeboxingStage.REFINE - session.tb_plan = object() # type: ignore[assignment] - session.base_snapshot = object() # type: ignore[assignment] - agent._sessions["thread-refine-submit"] = session - calls = {"submit": 0, "run_graph": 0} - - async def _decide_next_action(session_obj: Session, *, user_message: str): # noqa: ARG001 - _ = session_obj - return StageDecision(action="provide_info", submit_intent=True) - - async def _submit_pending_plan(*, session: Session, submit_mode: str, submit_attempt_kind: str): # noqa: ARG001 - _ = submit_mode, submit_attempt_kind - calls["submit"] += 1 - return TextMessage(content="submitted-from-refine", source="timeboxing_agent") - - async def _run_graph_turn(*, session: Session, user_text: str): # noqa: ARG001 - _ = session, user_text - calls["run_graph"] += 1 - return TextMessage(content="graph-progressed", source="timeboxing_agent") - - def _attach_presenter_blocks(*, reply: TextMessage, session: Session): - _ = reply - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = True - session.tb_plan = object() # type: ignore[assignment] - session.base_snapshot = object() # type: ignore[assignment] - return TextMessage(content="review-ready", source="timeboxing_agent") - - agent._decide_next_action = _decide_next_action - agent._submit_pending_plan = _submit_pending_plan - agent._run_graph_turn = _run_graph_turn - agent._attach_presenter_blocks = _attach_presenter_blocks - - out = await TimeboxingFlowAgent.on_user_reply( - agent, - TimeboxingUserReply( - channel_id="C1", - thread_ts="thread-refine-submit", - user_id="U1", - text="apply those edits and commit to calendar now", - ), - SimpleNamespace(), - ) - - assert isinstance(out, TextMessage) - assert out.content == "submitted-from-refine" - assert calls["submit"] == 1 - assert calls["run_graph"] == 1 - - -@pytest.mark.asyncio -async def test_on_user_reply_skeleton_commit_intent_carries_to_review_submit() -> None: - agent = _build_agent() - session = Session( - thread_ts="thread-skeleton-submit", - channel_id="C1", - user_id="U1", - committed=True, - planned_date="2026-02-27", - tz_name="UTC", - session_key="thread-skeleton-submit", - ) - session.stage = TimeboxingStage.SKELETON - session.tb_plan = object() # type: ignore[assignment] - session.base_snapshot = object() # type: ignore[assignment] - agent._sessions["thread-skeleton-submit"] = session - calls = {"submit": 0, "run_graph": 0} - - async def _decide_next_action(session_obj: Session, *, user_message: str): # noqa: ARG001 - if session_obj.stage == TimeboxingStage.SKELETON: - return StageDecision(action="proceed", submit_intent=True) - if session_obj.stage == TimeboxingStage.REFINE: - return StageDecision(action="proceed", submit_intent=False) - return StageDecision(action="provide_info", submit_intent=False) - - async def _submit_pending_plan(*, session: Session, submit_mode: str, submit_attempt_kind: str): # noqa: ARG001 - _ = submit_mode, submit_attempt_kind - calls["submit"] += 1 - session.pending_submit = False - return TextMessage(content="submitted-from-carry", source="timeboxing_agent") - - async def _run_graph_turn(*, session: Session, user_text: str): # noqa: ARG001 - _ = session, user_text - calls["run_graph"] += 1 - return TextMessage(content=f"graph-{calls['run_graph']}", source="timeboxing_agent") - - def _attach_presenter_blocks(*, reply: TextMessage, session: Session): - _ = reply - if calls["run_graph"] == 1: - session.stage = TimeboxingStage.REFINE - session.pending_submit = False - return TextMessage(content="refine-ready", source="timeboxing_agent") - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = True - session.tb_plan = object() # type: ignore[assignment] - session.base_snapshot = object() # type: ignore[assignment] - return TextMessage(content="review-ready", source="timeboxing_agent") - - agent._decide_next_action = _decide_next_action - agent._submit_pending_plan = _submit_pending_plan - agent._run_graph_turn = _run_graph_turn - agent._attach_presenter_blocks = _attach_presenter_blocks - - first = await TimeboxingFlowAgent.on_user_reply( - agent, - TimeboxingUserReply( - channel_id="C1", - thread_ts="thread-skeleton-submit", - user_id="U1", - text="looks good, commit this schedule now", - ), - SimpleNamespace(), - ) - - assert isinstance(first, TextMessage) - assert first.content == "refine-ready" - assert calls["submit"] == 0 - assert session.queued_submit_intent is True - - second = await TimeboxingFlowAgent.on_user_reply( - agent, - TimeboxingUserReply( - channel_id="C1", - thread_ts="thread-skeleton-submit", - user_id="U1", - text="proceed", - ), - SimpleNamespace(), - ) - - assert isinstance(second, TextMessage) - assert second.content == "submitted-from-carry" - assert calls["submit"] == 1 - assert calls["run_graph"] == 2 - assert session.queued_submit_intent is False - - -@pytest.mark.asyncio -async def test_on_user_reply_provide_info_preserves_queued_submit_intent() -> None: - agent = _build_agent() - session = Session( - thread_ts="thread-queued-preserve", - channel_id="C1", - user_id="U1", - committed=True, - planned_date="2026-02-27", - tz_name="UTC", - session_key="thread-queued-preserve", - ) - session.stage = TimeboxingStage.REFINE - session.queued_submit_intent = True - session.tb_plan = object() # type: ignore[assignment] - session.base_snapshot = object() # type: ignore[assignment] - agent._sessions["thread-queued-preserve"] = session - calls = {"submit": 0} - - async def _decide_next_action(session_obj: Session, *, user_message: str): # noqa: ARG001 - _ = session_obj - return StageDecision(action="provide_info", submit_intent=False) - - async def _submit_pending_plan(*, session: Session, submit_mode: str, submit_attempt_kind: str): # noqa: ARG001 - _ = session, submit_mode, submit_attempt_kind - calls["submit"] += 1 - session.pending_submit = False - return TextMessage(content="submitted", source="timeboxing_agent") - - async def _run_graph_turn(*, session: Session, user_text: str): # noqa: ARG001 - _ = user_text - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = True - return TextMessage(content="review-ready", source="timeboxing_agent") - - agent._decide_next_action = _decide_next_action - agent._submit_pending_plan = _submit_pending_plan - agent._run_graph_turn = _run_graph_turn - - out = await TimeboxingFlowAgent.on_user_reply( - agent, - TimeboxingUserReply( - channel_id="C1", - thread_ts="thread-queued-preserve", - user_id="U1", - text="actually add one more block first", - ), - SimpleNamespace(), - ) - - assert isinstance(out, TextMessage) - assert out.content == "submitted" - assert calls["submit"] == 1 - assert session.queued_submit_intent is False - - -@pytest.mark.asyncio -async def test_on_user_reply_cancel_clears_queued_submit_intent() -> None: - agent = _build_agent() - session = Session( - thread_ts="thread-queued-cancel", - channel_id="C1", - user_id="U1", - committed=True, - planned_date="2026-02-27", - tz_name="UTC", - session_key="thread-queued-cancel", - ) - session.stage = TimeboxingStage.REFINE - session.queued_submit_intent = True - agent._sessions["thread-queued-cancel"] = session - - async def _decide_next_action(session_obj: Session, *, user_message: str): # noqa: ARG001 - _ = session_obj - return StageDecision(action="cancel", submit_intent=False) - - async def _run_graph_turn(*, session: Session, user_text: str): # noqa: ARG001 - _ = session, user_text - return TextMessage(content="cancelled", source="timeboxing_agent") - - agent._decide_next_action = _decide_next_action - agent._run_graph_turn = _run_graph_turn - - out = await TimeboxingFlowAgent.on_user_reply( - agent, - TimeboxingUserReply( - channel_id="C1", - thread_ts="thread-queued-cancel", - user_id="U1", - text="cancel this session", - ), - SimpleNamespace(), - ) - - assert isinstance(out, TextMessage) - assert out.content == "cancelled" - assert session.queued_submit_intent is False - - -@pytest.mark.asyncio -async def test_on_user_reply_review_commit_explicit_commit_submits_after_review_render() -> None: - agent = _build_agent() - session = Session( - thread_ts="thread-review-submit", - channel_id="C1", - user_id="U1", - committed=True, - planned_date="2026-02-27", - tz_name="UTC", - session_key="thread-review-submit", - ) - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = False - session.tb_plan = object() # type: ignore[assignment] - session.base_snapshot = object() # type: ignore[assignment] - agent._sessions["thread-review-submit"] = session - calls = {"submit": 0, "run_graph": 0} - - async def _decide_next_action(session_obj: Session, *, user_message: str): # noqa: ARG001 - _ = session_obj - return StageDecision(action="proceed", submit_intent=True) - - async def _submit_pending_plan(*, session: Session, submit_mode: str, submit_attempt_kind: str): # noqa: ARG001 - _ = submit_mode, submit_attempt_kind - calls["submit"] += 1 - return TextMessage(content="submitted-after-review", source="timeboxing_agent") - - async def _run_graph_turn(*, session: Session, user_text: str): # noqa: ARG001 - _ = session, user_text - calls["run_graph"] += 1 - return TextMessage(content="review-progressed", source="timeboxing_agent") - - def _attach_presenter_blocks(*, reply: TextMessage, session: Session): - _ = reply - session.pending_submit = True - session.tb_plan = object() # type: ignore[assignment] - session.base_snapshot = object() # type: ignore[assignment] - return TextMessage(content="review-ready", source="timeboxing_agent") - - agent._decide_next_action = _decide_next_action - agent._submit_pending_plan = _submit_pending_plan - agent._run_graph_turn = _run_graph_turn - agent._attach_presenter_blocks = _attach_presenter_blocks - - out = await TimeboxingFlowAgent.on_user_reply( - agent, - TimeboxingUserReply( - channel_id="C1", - thread_ts="thread-review-submit", - user_id="U1", - text="yes commit it now", - ), - SimpleNamespace(), - ) - - assert isinstance(out, TextMessage) - assert out.content == "submitted-after-review" - assert calls["submit"] == 1 - assert calls["run_graph"] == 1 - - -@pytest.mark.asyncio -async def test_on_user_reply_review_commit_without_explicit_submit_intent_does_not_submit() -> None: - agent = _build_agent() - session = Session( - thread_ts="thread-review-no-submit", - channel_id="C1", - user_id="U1", - committed=True, - planned_date="2026-02-27", - tz_name="UTC", - session_key="thread-review-no-submit", - ) - from fateforger.agents.timeboxing.timebox import TBPlan - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = True - session.tb_plan = TBPlan(date="2026-02-27", tz="UTC", events=[]) - session.base_snapshot = TBPlan(date="2026-02-27", tz="UTC", events=[]) - agent._sessions["thread-review-no-submit"] = session - calls = {"submit": 0, "run_graph": 0} - - async def _decide_next_action(session_obj: Session, *, user_message: str): # noqa: ARG001 - _ = session_obj - return StageDecision(action="proceed", submit_intent=False) - - async def _submit_pending_plan(*, session: Session, submit_mode: str, submit_attempt_kind: str): # noqa: ARG001 - _ = session, submit_mode, submit_attempt_kind - calls["submit"] += 1 - return TextMessage(content="submitted", source="timeboxing_agent") - - async def _run_graph_turn(*, session: Session, user_text: str): # noqa: ARG001 - _ = session, user_text - calls["run_graph"] += 1 - return TextMessage(content="graph-progressed", source="timeboxing_agent") - - agent._decide_next_action = _decide_next_action - agent._submit_pending_plan = _submit_pending_plan - agent._run_graph_turn = _run_graph_turn - - agent._submit_attempt_kind = lambda s: "resubmit" - agent._attach_presenter_blocks = lambda reply, session: TextMessage(content=reply.content, source="agent") - - out = await TimeboxingFlowAgent.on_user_reply( - agent, - TimeboxingUserReply( - channel_id="C1", - thread_ts="thread-review-no-submit", - user_id="U1", - text="looks good, proceed", - ), - SimpleNamespace(), - ) - - assert isinstance(out, TextMessage) - assert out.content == "submitted" - assert calls["submit"] == 1 - - -@pytest.mark.asyncio -async def test_on_user_reply_explicit_commit_when_prereqs_missing_returns_actionable_non_submit() -> None: - agent = _build_agent() - session = Session( - thread_ts="thread-review-missing-prereqs", - channel_id="C1", - user_id="U1", - committed=True, - planned_date="2026-02-27", - tz_name="UTC", - session_key="thread-review-missing-prereqs", - ) - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = False - session.tb_plan = None - session.base_snapshot = None - agent._sessions["thread-review-missing-prereqs"] = session - calls = {"submit": 0} - - async def _decide_next_action(session_obj: Session, *, user_message: str): # noqa: ARG001 - _ = session_obj - return StageDecision(action="proceed", submit_intent=True) - - async def _run_graph_turn(*, session: Session, user_text: str): # noqa: ARG001 - _ = session, user_text - return TextMessage(content="review-stage", source="timeboxing_agent") - - def _attach_presenter_blocks(*, reply: TextMessage, session: Session): - _ = reply - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = False - session.tb_plan = None - session.base_snapshot = None - return TextMessage(content="still-missing", source="timeboxing_agent") - - async def _submit_pending_plan(*, session: Session, submit_mode: str, submit_attempt_kind: str): # noqa: ARG001 - _ = session, submit_mode, submit_attempt_kind - calls["submit"] += 1 - return TextMessage(content="submitted", source="timeboxing_agent") - - agent._decide_next_action = _decide_next_action - agent._run_graph_turn = _run_graph_turn - agent._attach_presenter_blocks = _attach_presenter_blocks - agent._submit_pending_plan = _submit_pending_plan - - out = await TimeboxingFlowAgent.on_user_reply( - agent, - TimeboxingUserReply( - channel_id="C1", - thread_ts="thread-review-missing-prereqs", - user_id="U1", - text="commit to calendar now", - ), - SimpleNamespace(), - ) - - assert isinstance(out, TextMessage) - assert "Cannot submit yet because the plan is incomplete." in out.content - assert calls["submit"] == 0 - - -@pytest.mark.asyncio -async def test_on_user_reply_memory_review_bypasses_stage_progression() -> None: - agent = _build_agent() - session = Session( - thread_ts="thread-memory", - channel_id="C1", - user_id="U1", - committed=True, - planned_date="2026-02-27", - tz_name="UTC", - session_key="thread-memory", - ) - session.stage = TimeboxingStage.COLLECT_CONSTRAINTS - agent._sessions["thread-memory"] = session - calls = {"run_graph": 0} - - async def _run_graph_turn(*, session: Session, user_text: str): # noqa: ARG001 - calls["run_graph"] += 1 - return TextMessage(content="graph-progressed", source="timeboxing_agent") - - async def _memory_turn(*, session: Session, user_message: str): # noqa: ARG001 - return TextMessage(content="memory-reviewed", source="timeboxing_agent") - - agent._run_graph_turn = _run_graph_turn - agent._maybe_handle_memory_review_turn = _memory_turn - - out = await TimeboxingFlowAgent.on_user_reply( - agent, - TimeboxingUserReply( - channel_id="C1", - thread_ts="thread-memory", - user_id="U1", - text="Which memories are active right now?", - ), - SimpleNamespace(), - ) - - assert isinstance(out, TextMessage) - assert out.content == "memory-reviewed" - assert calls["run_graph"] == 0 diff --git a/tests/unit/timeboxing/test_timeboxing_session_logging.py b/tests/unit/timeboxing/test_timeboxing_session_logging.py deleted file mode 100644 index 83e5501f..00000000 --- a/tests/unit/timeboxing/test_timeboxing_session_logging.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Unit tests for per-session timeboxing debug log files.""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -pytest.importorskip("autogen_agentchat") - -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent - - -def test_session_debug_logging_writes_session_file( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Session debug events should be written to a dedicated session log file.""" - monkeypatch.setenv("TIMEBOX_SESSION_DEBUG_LOG", "1") - monkeypatch.setenv("TIMEBOX_SESSION_LOG_DIR", str(tmp_path)) - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._session_debug_loggers = {} - session = Session( - thread_ts="1771026031.540599", - channel_id="C0AA6HC1RJL", - user_id="U095637NL8P", - planned_date="2026-02-14", - tz_name="Europe/Amsterdam", - session_key="C0AA6HC1RJL:1771026031.540599", - ) - - TimeboxingFlowAgent._session_debug( - agent, - session, - "calendar_prefetch_start", - timeout_s=4.0, - ) - TimeboxingFlowAgent._close_session_debug_logger(agent, session.session_key or "") - - assert session.debug_log_path is not None - log_path = Path(session.debug_log_path) - assert log_path.exists() - content = log_path.read_text(encoding="utf-8") - assert '"event": "calendar_prefetch_start"' in content - assert '"session_key": "C0AA6HC1RJL:1771026031.540599"' in content - - -def test_session_debug_logging_disabled_by_default( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Session debug logs should stay off when explicit env flag is false.""" - monkeypatch.setenv("TIMEBOX_SESSION_DEBUG_LOG", "0") - monkeypatch.setenv("TIMEBOX_SESSION_LOG_DIR", str(tmp_path)) - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._session_debug_loggers = {} - session = Session( - thread_ts="t1", - channel_id="c1", - user_id="u1", - planned_date="2026-02-14", - tz_name="UTC", - session_key="c1:t1", - ) - - TimeboxingFlowAgent._session_debug(agent, session, "noop") - - assert session.debug_log_path is None - assert list(tmp_path.iterdir()) == [] diff --git a/tests/unit/timeboxing/test_timeboxing_stage1_deterministic_fast_path.py b/tests/unit/timeboxing/test_timeboxing_stage1_deterministic_fast_path.py deleted file mode 100644 index c11d4fa4..00000000 --- a/tests/unit/timeboxing/test_timeboxing_stage1_deterministic_fast_path.py +++ /dev/null @@ -1,105 +0,0 @@ -from __future__ import annotations - -import pytest - -pytest.importorskip("autogen_agentchat") - -import fateforger.agents.timeboxing.agent as timeboxing_agent_mod -from fateforger.agents.timeboxing.agent import TimeboxingFlowAgent -from fateforger.agents.timeboxing.stage_gating import ( - CAPTURE_INPUTS_PROMPT, - COLLECT_CONSTRAINTS_PROMPT, - DECISION_PROMPT, - REVIEW_COMMIT_PROMPT, - TIMEBOX_SUMMARY_PROMPT, - StageDecision, - StageGateOutput, -) -from fateforger.core.config import settings - - -@pytest.mark.asyncio -async def test_stage_collect_constraints_agent_keeps_search_tool( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Stage 1 keeps search available while remaining deterministic-first by prompt policy. - - Validates that _build_one_shot_agent forwards tools correctly to stages 1/2 - and uses schema-in-prompt (output_content_type=None) for summary/review agents. - """ - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._model_client = object() - agent._constraint_search_tool = None - - captured_tools: dict[str, object] = {} - captured_output_types: dict[str, object] = {} - - class _FakeAssistantAgent: - def __init__( - self, - *, - name: str, - model_client, - tools, - output_content_type, - system_message: str, - reflect_on_tool_use: bool, - max_tool_iterations: int, - ) -> None: - _ = ( - model_client, - system_message, - reflect_on_tool_use, - max_tool_iterations, - ) - captured_tools[name] = tools - captured_output_types[name] = output_content_type - - monkeypatch.setattr(timeboxing_agent_mod, "AssistantAgent", _FakeAssistantAgent) - monkeypatch.setattr( - timeboxing_agent_mod, - "assert_strict_tools_for_structured_output", - lambda **_kwargs: None, - ) - monkeypatch.setattr(settings, "notion_timeboxing_parent_page_id", "parent") - - fake_search_tool = "search_constraints_tool" - - # Stages 1 and 2 carry the constraint search tool. - agent._build_one_shot_agent( - "StageCollectConstraints", - COLLECT_CONSTRAINTS_PROMPT, - StageGateOutput, - tools=[fake_search_tool], - max_tool_iterations=2, - ) - agent._build_one_shot_agent( - "StageCaptureInputs", - CAPTURE_INPUTS_PROMPT, - StageGateOutput, - tools=[fake_search_tool], - max_tool_iterations=3, - ) - # Summary and ReviewCommit use schema-in-prompt (output_content_type=None). - agent._build_one_shot_agent( - "StageTimeboxSummary", - TIMEBOX_SUMMARY_PROMPT, - StageGateOutput, - structured_output=False, - ) - agent._build_one_shot_agent( - "StageReviewCommit", - REVIEW_COMMIT_PROMPT, - StageGateOutput, - structured_output=False, - ) - # Decision uses structured output. - agent._build_one_shot_agent("StageDecision", DECISION_PROMPT, StageDecision) - - assert captured_tools["StageCollectConstraints"] == [fake_search_tool] - assert captured_tools["StageCaptureInputs"] == [fake_search_tool] - assert captured_output_types["StageCollectConstraints"] is not None - assert captured_output_types["StageCaptureInputs"] is not None - assert captured_output_types["StageTimeboxSummary"] is None - assert captured_output_types["StageReviewCommit"] is None - assert captured_output_types["StageDecision"] is not None diff --git a/tests/unit/timeboxing/test_timeboxing_stage3_markdown_block.py b/tests/unit/timeboxing/test_timeboxing_stage3_markdown_block.py deleted file mode 100644 index c02dc1f8..00000000 --- a/tests/unit/timeboxing/test_timeboxing_stage3_markdown_block.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Regression tests for Stage 3 markdown block rendering.""" - -from __future__ import annotations - -from datetime import date, time - -import pytest - -pytest.importorskip("autogen_agentchat") - -from fateforger.agents.timeboxing.agent import TimeboxingFlowAgent -from fateforger.agents.timeboxing.tb_models import AfterPrev, ET, FixedWindow, TBEvent, TBPlan - - -def test_render_markdown_summary_blocks_uses_markdown_block_type() -> None: - """Stage 3 overview should be emitted as a Slack markdown block.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - blocks = TimeboxingFlowAgent._render_markdown_summary_blocks( - agent, - text="## Day Overview\n- Focus block", - ) - - assert len(blocks) == 1 - assert blocks[0]["type"] == "markdown" - assert blocks[0]["text"] == "## Day Overview\n- Focus block" - - -def test_tb_plan_overview_markdown_prefers_coarse_duration_for_flexible_blocks() -> None: - """Stage 3 fallback markdown should show anchored times + coarse flexible durations.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - plan = TBPlan( - date=date(2026, 2, 14), - tz="Europe/Amsterdam", - events=[ - TBEvent( - n="Anchor Meeting", - t=ET.M, - p=FixedWindow(st=time(9, 0), et=time(10, 0)), - ), - TBEvent( - n="Deep Work", - t=ET.DW, - p=AfterPrev(dur="PT90M"), - ), - ], - ) - - text = TimeboxingFlowAgent._tb_plan_overview_markdown(agent, plan) - - assert "09:00-10:00 **Anchor Meeting**" in text - assert "**Deep Work** β€” ~1h30m" in text - assert "10:00-11:30 **Deep Work**" not in text diff --git a/tests/unit/timeboxing/test_timeboxing_stage_actions.py b/tests/unit/timeboxing/test_timeboxing_stage_actions.py deleted file mode 100644 index a7554b90..00000000 --- a/tests/unit/timeboxing/test_timeboxing_stage_actions.py +++ /dev/null @@ -1,331 +0,0 @@ -"""Unit tests for deterministic stage-control actions.""" - -from __future__ import annotations - -from datetime import date, time -from unittest.mock import AsyncMock - -import pytest - -pytest.importorskip("autogen_agentchat") - -from autogen_agentchat.messages import TextMessage - -from fateforger.agents.timeboxing.agent import ( - Session, - TimeboxingFlowAgent, - _wrap_with_constraint_review, -) -from fateforger.agents.timeboxing.messages import TimeboxingStageAction -from fateforger.agents.timeboxing.preferences import ( - Constraint, - ConstraintNecessity, - ConstraintScope, - ConstraintSource, - ConstraintStatus, -) -from fateforger.agents.timeboxing.stage_gating import TimeboxingStage -from fateforger.slack_bot.messages import SlackBlockMessage -from fateforger.slack_bot.timeboxing_stage_actions import ( - FF_TIMEBOX_STAGE_BACK_ACTION_ID, - FF_TIMEBOX_STAGE_CANCEL_ACTION_ID, - FF_TIMEBOX_STAGE_PROCEED_ACTION_ID, - FF_TIMEBOX_STAGE_REDO_ACTION_ID, -) -from fateforger.slack_bot.timeboxing_submit import FF_TIMEBOX_CONFIRM_SUBMIT_ACTION_ID -from fateforger.agents.timeboxing.tb_models import FixedWindow, TBEvent, TBPlan - - -class _Ctx: - """Minimal message context test double.""" - - topic_id = None - sender = None - - -def _review_plan() -> TBPlan: - """Return a minimal valid plan fixture for review-stage block rendering.""" - return TBPlan( - events=[ - TBEvent( - n="Focus", - d="", - t="DW", - p=FixedWindow(st=time(9, 0), et=time(10, 0)), - ) - ], - date=date(2026, 2, 27), - tz="Europe/Amsterdam", - ) - - -def _collect_action_ids(blocks: list[dict]) -> list[str]: - """Extract action IDs from Slack action blocks.""" - return [ - str(element.get("action_id")) - for block in blocks - for element in (block.get("elements") or []) - if isinstance(element, dict) - ] - - -def _action_text_for(blocks: list[dict], action_id: str) -> str | None: - """Return button text for an action ID when present.""" - for block in blocks: - for element in (block.get("elements") or []): - if not isinstance(element, dict): - continue - if str(element.get("action_id")) != action_id: - continue - text = element.get("text") - if isinstance(text, dict): - return str(text.get("text") or "") - return None - - -def test_render_stage_action_blocks_ready_includes_proceed() -> None: - """Ready stages should include deterministic Proceed button.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.stage = TimeboxingStage.CAPTURE_INPUTS - session.stage_ready = True - - blocks = agent._render_stage_action_blocks(session=session) - action_ids = _collect_action_ids(blocks) - assert FF_TIMEBOX_STAGE_PROCEED_ACTION_ID in action_ids - assert FF_TIMEBOX_STAGE_BACK_ACTION_ID in action_ids - assert FF_TIMEBOX_STAGE_REDO_ACTION_ID in action_ids - assert FF_TIMEBOX_STAGE_CANCEL_ACTION_ID in action_ids - - -def test_render_stage_action_blocks_not_ready_hides_proceed() -> None: - """Proceed should be hidden until stage criteria are met.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.stage = TimeboxingStage.CAPTURE_INPUTS - session.stage_ready = False - - blocks = agent._render_stage_action_blocks(session=session) - action_ids = _collect_action_ids(blocks) - assert FF_TIMEBOX_STAGE_PROCEED_ACTION_ID not in action_ids - assert FF_TIMEBOX_STAGE_BACK_ACTION_ID in action_ids - assert FF_TIMEBOX_STAGE_REDO_ACTION_ID in action_ids - assert FF_TIMEBOX_STAGE_CANCEL_ACTION_ID in action_ids - - -def test_render_stage_action_blocks_refine_with_undo_hides_proceed() -> None: - """Refine stage with a pending undo should swap Proceed for Undo last update.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.stage = TimeboxingStage.REFINE - session.stage_ready = True - session.tb_plan = _review_plan() - session.last_refine_undo_tb_plan = _review_plan() - - blocks = agent._render_stage_action_blocks(session=session) - action_ids = _collect_action_ids(blocks) - assert FF_TIMEBOX_STAGE_PROCEED_ACTION_ID not in action_ids - assert FF_TIMEBOX_STAGE_REDO_ACTION_ID in action_ids - assert _action_text_for(blocks, FF_TIMEBOX_STAGE_REDO_ACTION_ID) == "Undo last update" - - -@pytest.mark.asyncio -async def test_stage_action_proceed_requires_stage_ready() -> None: - """Proceed action should be rejected until stage criteria are met.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._sessions = {} - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.stage = TimeboxingStage.COLLECT_CONSTRAINTS - session.stage_ready = False - session.stage_missing = ["timezone", "work window"] - session.stage_question = "What timezone should I use?" - agent._sessions["t1"] = session - - agent._attach_presenter_blocks = TimeboxingFlowAgent._attach_presenter_blocks.__get__( # type: ignore[attr-defined] - agent, TimeboxingFlowAgent - ) - agent._publish_update = AsyncMock() # type: ignore[attr-defined] - - response = await agent.on_stage_action( - TimeboxingStageAction( - channel_id="c1", - thread_ts="t1", - user_id="u1", - action="proceed", - ), - _Ctx(), - ) - - assert isinstance(response, SlackBlockMessage) - assert "Cannot proceed yet" in response.text - assert "timezone" in response.text - action_ids = _collect_action_ids(response.blocks) - assert FF_TIMEBOX_STAGE_PROCEED_ACTION_ID not in action_ids - assert session.stage == TimeboxingStage.COLLECT_CONSTRAINTS - - -@pytest.mark.asyncio -async def test_stage_action_proceed_advances_and_replaces_message() -> None: - """Proceed action should advance stage and return the next rendered message.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._sessions = {} - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.stage = TimeboxingStage.COLLECT_CONSTRAINTS - session.stage_ready = True - agent._sessions["t1"] = session - - async def _run_graph_turn(*, session: Session, user_text: str) -> TextMessage: - _ = (session, user_text) - assert session.force_stage_rerun is True - assert user_text == "" - return TextMessage(content="Stage 2/5 (CaptureInputs)", source="PresenterNode") - - async def _wrap(reply: TextMessage, *, session: Session) -> TextMessage: - _ = session - return reply - - agent._run_graph_turn = AsyncMock(side_effect=_run_graph_turn) # type: ignore[attr-defined] - agent._maybe_wrap_constraint_review = AsyncMock(side_effect=_wrap) # type: ignore[attr-defined] - agent._attach_presenter_blocks = lambda *, reply, session: reply # type: ignore[attr-defined] - agent._publish_update = AsyncMock() # type: ignore[attr-defined] - - response = await agent.on_stage_action( - TimeboxingStageAction( - channel_id="c1", - thread_ts="t1", - user_id="u1", - action="proceed", - ), - _Ctx(), - ) - - assert isinstance(response, TextMessage) - assert "CaptureInputs" in response.content - assert session.stage == TimeboxingStage.CAPTURE_INPUTS - - -@pytest.mark.asyncio -async def test_stage_action_redo_uses_refine_undo_path() -> None: - """Redo button should use local refine undo when available.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._sessions = {} - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.stage = TimeboxingStage.REFINE - session.stage_ready = True - session.last_refine_undo_tb_plan = _review_plan() - agent._sessions["t1"] = session - - undone_reply = TextMessage(content="Undone update", source="timeboxing_agent") - agent._undo_last_refine_update = AsyncMock(return_value=undone_reply) # type: ignore[attr-defined] - agent._attach_presenter_blocks = lambda *, reply, session: reply # type: ignore[attr-defined] - agent._publish_update = AsyncMock() # type: ignore[attr-defined] - agent._run_graph_turn = AsyncMock() # type: ignore[attr-defined] - agent._maybe_wrap_constraint_review = AsyncMock() # type: ignore[attr-defined] - - response = await agent.on_stage_action( - TimeboxingStageAction( - channel_id="c1", - thread_ts="t1", - user_id="u1", - action="redo", - ), - _Ctx(), - ) - - assert isinstance(response, TextMessage) - assert response.content == "Undone update" - agent._undo_last_refine_update.assert_awaited_once() # type: ignore[attr-defined] - agent._run_graph_turn.assert_not_awaited() # type: ignore[attr-defined] - - -def test_attach_presenter_blocks_review_stage_enables_auto_submit_state() -> None: - """Review stage should enable pending submit without explicit submit controls.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.stage = TimeboxingStage.REVIEW_COMMIT - session.stage_ready = True - session.tb_plan = _review_plan() - session.base_snapshot = _review_plan() - session.pending_presenter_blocks = [] - - wrapped = agent._attach_presenter_blocks( - reply=TextMessage(content="review", source="PresenterNode"), - session=session, - ) - - assert isinstance(wrapped, SlackBlockMessage) - action_ids = _collect_action_ids(wrapped.blocks) - assert FF_TIMEBOX_CONFIRM_SUBMIT_ACTION_ID not in action_ids - assert session.pending_submit is True - - -def test_attach_presenter_blocks_appends_stage_actions() -> None: - """Presenter output should include deterministic stage-control actions.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.stage = TimeboxingStage.CAPTURE_INPUTS - session.stage_ready = True - session.pending_presenter_blocks = [] - - wrapped = agent._attach_presenter_blocks( - reply=TextMessage(content="hello", source="PresenterNode"), - session=session, - ) - - assert isinstance(wrapped, SlackBlockMessage) - action_ids = _collect_action_ids(wrapped.blocks) - assert FF_TIMEBOX_STAGE_PROCEED_ACTION_ID in action_ids - - -def _preview_constraint(name: str, *, uid: str) -> Constraint: - return Constraint( - user_id="u1", - channel_id="c1", - thread_ts="t1", - name=name, - description=name, - necessity=ConstraintNecessity.SHOULD, - status=ConstraintStatus.PROPOSED, - source=ConstraintSource.USER, - scope=ConstraintScope.SESSION, - hints={"uid": uid}, - ) - - -def test_render_constraints_preview_blocks_labels_new_active_and_selected_counts() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.stage = TimeboxingStage.REFINE - session.active_constraints = [ - _preview_constraint(f"Constraint {idx}", uid=f"uid-{idx}") for idx in range(6) - ] - session.active_constraints_raw_count = 20 - session.active_constraints_applicable_count = 6 - session.last_extracted_constraints_count = 6 - session.last_refine_selected_constraints_count = 4 - - blocks = agent._render_constraints_preview_blocks(session=session, limit=3) - preview_text = str(blocks[1]["text"]["text"]) - - assert "Newly extracted: 6." in preview_text - assert "Active total (applicable now): 6." in preview_text - assert "Selected for Refine patching: 4." in preview_text - assert "Showing the top 3 of 6." in preview_text - - -def test_wrap_with_constraint_review_labels_newly_extracted_vs_active_total() -> None: - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.active_constraints_raw_count = 173 - session.active_constraints_applicable_count = 167 - session.last_extracted_constraints_count = 6 - wrapped = _wrap_with_constraint_review( - TextMessage(content="Stage 1", source="PresenterNode"), - constraints=[_preview_constraint(f"Constraint {idx}", uid=f"uid-{idx}") for idx in range(6)], - session=session, - ) - - assert isinstance(wrapped, SlackBlockMessage) - header = str(wrapped.blocks[2]["text"]["text"]) - assert "Newly extracted this turn: 6." in header - assert "Active total (applicable now): 167." in header - assert "Raw active rows before filtering: 173." in header diff --git a/tests/unit/timeboxing/test_timeboxing_stage_message_template_coverage.py b/tests/unit/timeboxing/test_timeboxing_stage_message_template_coverage.py deleted file mode 100644 index a68cdc59..00000000 --- a/tests/unit/timeboxing/test_timeboxing_stage_message_template_coverage.py +++ /dev/null @@ -1,119 +0,0 @@ -"""Tests for Stage 1 constraint-template coverage rendering.""" - -from __future__ import annotations - -import pytest - -pytest.importorskip("autogen_agentchat") - -from fateforger.agents.timeboxing.agent import TimeboxingFlowAgent -from fateforger.agents.timeboxing.preferences import ( - Constraint, - ConstraintNecessity, - ConstraintScope, - ConstraintSource, - ConstraintStatus, -) -from fateforger.agents.timeboxing.stage_gating import StageGateOutput, TimeboxingStage - - -def test_collect_constraints_message_omits_template_coverage_even_when_facts_present() -> None: - """Stage 1 message should not include template-coverage sections in Slack output.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - gate = StageGateOutput( - stage_id=TimeboxingStage.COLLECT_CONSTRAINTS, - ready=False, - summary=["Anchored brunch and dinner windows."], - missing=["timezone"], - question="What timezone should I use for this plan?", - facts={ - "constraint_overview": { - "durable_applies": ["No meetings before 10:00"], - "day_specific_applies": ["Dinner prep starts at 18:00"], - }, - "constraint_template": { - "filled_fields": ["name", "description", "necessity", "scope"], - "useful_next_fields": ["days_of_week", "timezone", "selector"], - }, - }, - ) - - message = agent._format_stage_message(gate, constraints=[], immovables=[]) - - assert "Constraint Template Coverage:" not in message - assert "Filled:" not in message - assert "Durable Applies:" not in message - assert "Day-Specific Applies:" not in message - assert "Useful Next Info:" not in message - - -def test_collect_constraints_message_omits_template_section_when_facts_missing() -> None: - """Stage 1 message should not post-process strings when template facts are missing.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - gate = StageGateOutput( - stage_id=TimeboxingStage.COLLECT_CONSTRAINTS, - ready=False, - summary=["Anchored morning rhythm."], - missing=["timezone", "days_of_week"], - question="Any weekday/weekend differences I should account for?", - facts={}, - ) - constraint = Constraint( - name="Morning deep work", - description="Protect morning deep-work window.", - necessity=ConstraintNecessity.MUST, - user_id="U1", - status=ConstraintStatus.LOCKED, - source=ConstraintSource.USER, - scope=ConstraintScope.PROFILE, - timezone="Europe/Amsterdam", - ) - - message = agent._format_stage_message(gate, constraints=[constraint], immovables=[]) - - assert "Constraint Template Coverage:" not in message - - -def test_collect_constraints_message_deduplicates_only() -> None: - """Stage 1 summary should preserve model wording and only deduplicate exact repeats.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - gate = StageGateOutput( - stage_id=TimeboxingStage.COLLECT_CONSTRAINTS, - ready=False, - summary=[ - "February 14th, 2026 is our canvas.", - "Timezone set to Europe/Amsterdam.", - "Anchored brunch at 11:30.", - "Anchored brunch at 11:30.", - "No fixed anchors or windows are currently defined.", - ], - missing=["work window"], - question="Any hard appointments?", - facts={}, - ) - - message = agent._format_stage_message(gate, constraints=[], immovables=[]) - - assert "February 14th, 2026 is our canvas." in message - assert "Timezone set to Europe/Amsterdam." in message - assert "No fixed anchors or windows are currently defined." in message - assert message.count("Anchored brunch at 11:30.") == 1 - - -def test_not_ready_stage_message_leads_with_missing_before_summary() -> None: - """Not-ready stage output should prioritize unanswered required inputs first.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - gate = StageGateOutput( - stage_id=TimeboxingStage.CAPTURE_INPUTS, - ready=False, - summary=["Captured Daily One Thing and two tasks."], - missing=["deep-work block count"], - question="How many deep-work blocks should I reserve?", - facts={}, - ) - - message = agent._format_stage_message(gate, constraints=[], immovables=[]) - - assert "Need Before Proceeding:" in message - assert "What I Have So Far:" in message - assert message.index("Need Before Proceeding:") < message.index("What I Have So Far:") diff --git a/tests/unit/timeboxing/test_timeboxing_stateless_agents.py b/tests/unit/timeboxing/test_timeboxing_stateless_agents.py deleted file mode 100644 index 20f7412c..00000000 --- a/tests/unit/timeboxing/test_timeboxing_stateless_agents.py +++ /dev/null @@ -1,503 +0,0 @@ -"""Stateless-agent contract tests for TimeboxingFlowAgent. - -Architecture invariant (enforced here): - Every LLM call in TimeboxingFlowAgent must use a **fresh** AssistantAgent - instance so that each invocation receives ONLY: - - last_message + extracted constraints/memories + current stage artifact - - …and NEVER the accumulated conversation history from previous turns. - -Covered call sites ------------------- -Stage-gating agents (via _build_one_shot_agent): - - _run_stage_gate (COLLECT_CONSTRAINTS, CAPTURE_INPUTS) - - _run_timebox_summary - - _run_review_commit - - _decide_next_action - -NLU agents (via nlu factory functions): - - _interpret_planned_date β†’ build_planned_date_interpreter() - - _decide_memory_review_turnβ†’ build_memory_review_router() - - _interpret_constraints β†’ build_constraint_interpreter() - -For each call site we verify: - a) A brand-new AssistantAgent is instantiated on EVERY call (not cached). - b) The agent receives exactly ONE TextMessage per call (single-turn). - c) After 2 sequential calls the second agent starts with an empty message list. -""" - -from __future__ import annotations - -import asyncio -import json -from datetime import datetime -from typing import Any -from unittest.mock import AsyncMock - -import pytest - -pytest.importorskip("autogen_agentchat") - -import fateforger.agents.timeboxing.agent as agent_mod -import fateforger.agents.timeboxing.nlu as nlu_mod -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.nlu import MemoryReviewDecision -from fateforger.agents.timeboxing.stage_gating import StageDecision, StageGateOutput - -# --------------------------------------------------------------------------- -# Shared stubs -# --------------------------------------------------------------------------- - - -class _InstantiationCounter: - """Tracks every AssistantAgent instantiation and the messages it receives.""" - - def __init__(self, response_content: str = "{}") -> None: - self.instances: list[_FakeAgent] = [] - self.response_content = response_content - - def make_agent(self, **kwargs: Any) -> "_FakeAgent": # noqa: ANN401 - inst = _FakeAgent( - response_content=self.response_content, - kwargs=kwargs, - ) - self.instances.append(inst) - return inst - - -class _FakeAgent: - """Minimal AssistantAgent stand-in that records on_messages calls.""" - - def __init__(self, *, response_content: str, kwargs: dict[str, Any]) -> None: - self.response_content = response_content - self.init_kwargs = kwargs - self.calls: list[list[Any]] = [] # each element = messages list passed in - - async def on_messages(self, messages: list[Any], token: Any) -> Any: - _ = token - self.calls.append(list(messages)) - mock_msg = AsyncMock() - mock_msg.content = self.response_content - result = AsyncMock() - result.chat_message = mock_msg - return result - - -def _make_agent(*, model_client: Any) -> TimeboxingFlowAgent: # noqa: ANN401 - """Return a minimally initialised TimeboxingFlowAgent via __new__.""" - a = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - a._model_client = model_client - a._constraint_model_client = model_client - a._session_debug_loggers = {} - return a - - -# --------------------------------------------------------------------------- -# NLU factory fresh-agent-per-call tests -# --------------------------------------------------------------------------- - - -class TestInterpretPlannedDateStateless: - """_interpret_planned_date must build a new agent on every call.""" - - @pytest.mark.asyncio - async def test_creates_new_agent_per_call( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - counter = _InstantiationCounter( - response_content=json.dumps({"planned_date": "2026-03-01"}) - ) - - def _factory(*, model_client: Any) -> _FakeAgent: # noqa: ANN401 - return counter.make_agent(model_client=model_client) - - monkeypatch.setattr(nlu_mod, "AssistantAgent", _FakeAgent) - monkeypatch.setattr(agent_mod, "build_planned_date_interpreter", _factory) - - agent = _make_agent(model_client=object()) - now = datetime(2026, 3, 1, 9, 0) - - await agent._interpret_planned_date("tomorrow", now=now, tz_name="UTC") - await agent._interpret_planned_date("next Monday", now=now, tz_name="UTC") - - # Two separate agents must have been created β€” one per call. - assert len(counter.instances) == 2, ( - "_interpret_planned_date must create a fresh agent on every call, " - f"but only {len(counter.instances)} instance(s) were created for 2 calls." - ) - - @pytest.mark.asyncio - async def test_each_call_receives_single_message( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - counter = _InstantiationCounter( - response_content=json.dumps({"planned_date": "2026-03-01"}) - ) - - def _factory(*, model_client: Any) -> _FakeAgent: # noqa: ANN401 - return counter.make_agent(model_client=model_client) - - monkeypatch.setattr(agent_mod, "build_planned_date_interpreter", _factory) - - agent = _make_agent(model_client=object()) - now = datetime(2026, 3, 1, 9, 0) - - await agent._interpret_planned_date("tomorrow", now=now, tz_name="UTC") - await agent._interpret_planned_date("next Monday", now=now, tz_name="UTC") - - for i, inst in enumerate(counter.instances): - assert ( - len(inst.calls) == 1 - ), f"Agent {i} should receive exactly 1 on_messages call; got {len(inst.calls)}" - assert ( - len(inst.calls[0]) == 1 - ), f"Agent {i} should receive exactly 1 message per call; got {len(inst.calls[0])}" - - @pytest.mark.asyncio - async def test_second_call_does_not_see_first_call_payload( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """The second agent must not carry the first call's messages.""" - counter = _InstantiationCounter( - response_content=json.dumps({"planned_date": "2026-03-01"}) - ) - - def _factory(*, model_client: Any) -> _FakeAgent: # noqa: ANN401 - return counter.make_agent(model_client=model_client) - - monkeypatch.setattr(agent_mod, "build_planned_date_interpreter", _factory) - - agent = _make_agent(model_client=object()) - now = datetime(2026, 3, 1, 9, 0) - - await agent._interpret_planned_date("tomorrow", now=now, tz_name="UTC") - await agent._interpret_planned_date("next Monday", now=now, tz_name="UTC") - - # Second agent must not have received the first agent's messages. - first_payload = json.loads(counter.instances[0].calls[0][0].content) - second_payload = json.loads(counter.instances[1].calls[0][0].content) - assert ( - first_payload["text"] != second_payload["text"] - ), "The two calls had different input text β€” confirming independent payloads." - # Each agent only received its own single message. - assert len(counter.instances[1].calls[0]) == 1 - - -class TestDecideMemoryReviewTurnStateless: - """_decide_memory_review_turn must build a new agent on every call.""" - - @pytest.mark.asyncio - async def test_creates_new_agent_per_call( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - decision_json = json.dumps({"action": "none"}) - counter = _InstantiationCounter(response_content=decision_json) - - def _factory(*, model_client: Any) -> _FakeAgent: # noqa: ANN401 - return counter.make_agent(model_client=model_client) - - monkeypatch.setattr(agent_mod, "build_memory_review_router", _factory) - - agent = _make_agent(model_client=object()) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - - await agent._decide_memory_review_turn( - session=session, user_message="show me my constraints" - ) - await agent._decide_memory_review_turn( - session=session, user_message="update sleep time" - ) - - assert len(counter.instances) == 2, ( - "_decide_memory_review_turn must create a fresh agent per call, " - f"got {len(counter.instances)} instance(s) for 2 calls." - ) - - @pytest.mark.asyncio - async def test_each_call_receives_single_message( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - decision_json = json.dumps({"action": "none"}) - counter = _InstantiationCounter(response_content=decision_json) - - def _factory(*, model_client: Any) -> _FakeAgent: # noqa: ANN401 - return counter.make_agent(model_client=model_client) - - monkeypatch.setattr(agent_mod, "build_memory_review_router", _factory) - - agent = _make_agent(model_client=object()) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - - await agent._decide_memory_review_turn(session=session, user_message="msg1") - await agent._decide_memory_review_turn(session=session, user_message="msg2") - - for i, inst in enumerate(counter.instances): - assert len(inst.calls) == 1, f"Agent {i} should receive exactly 1 call" - assert ( - len(inst.calls[0]) == 1 - ), f"Agent {i} should receive exactly 1 message" - - @pytest.mark.asyncio - async def test_second_agent_does_not_contain_first_call_message( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - decision_json = json.dumps({"action": "none"}) - counter = _InstantiationCounter(response_content=decision_json) - - def _factory(*, model_client: Any) -> _FakeAgent: # noqa: ANN401 - return counter.make_agent(model_client=model_client) - - monkeypatch.setattr(agent_mod, "build_memory_review_router", _factory) - - agent = _make_agent(model_client=object()) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - - await agent._decide_memory_review_turn( - session=session, user_message="first message" - ) - await agent._decide_memory_review_turn( - session=session, user_message="second message" - ) - - first_payload = json.loads(counter.instances[0].calls[0][0].content) - second_payload = json.loads(counter.instances[1].calls[0][0].content) - assert first_payload["user_message"] == "first message" - assert second_payload["user_message"] == "second message" - # Second agent must not know about first call. - assert len(counter.instances[1].calls[0]) == 1 - - -class TestInterpretConstraintsStateless: - """_interpret_constraints must build a new agent on every call.""" - - @pytest.mark.asyncio - async def test_creates_new_agent_per_call( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - result_json = json.dumps( - { - "constraints": [], - "scope": "session", - "entities": [], - "date_references": [], - "should_extract": False, - } - ) - counter = _InstantiationCounter(response_content=result_json) - - def _factory(*, model_client: Any) -> _FakeAgent: # noqa: ANN401 - return counter.make_agent(model_client=model_client) - - monkeypatch.setattr(agent_mod, "build_constraint_interpreter", _factory) - - agent = _make_agent(model_client=object()) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - - await agent._interpret_constraints( - session, text="no meetings before 9am", is_initial=True - ) - await agent._interpret_constraints(session, text="gym at 6pm", is_initial=False) - - assert len(counter.instances) == 2, ( - "_interpret_constraints must create a fresh agent per call, " - f"got {len(counter.instances)} instance(s) for 2 calls." - ) - - @pytest.mark.asyncio - async def test_each_call_receives_single_message( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - result_json = json.dumps( - { - "constraints": [], - "scope": "session", - "entities": [], - "date_references": [], - "should_extract": False, - } - ) - counter = _InstantiationCounter(response_content=result_json) - - def _factory(*, model_client: Any) -> _FakeAgent: # noqa: ANN401 - return counter.make_agent(model_client=model_client) - - monkeypatch.setattr(agent_mod, "build_constraint_interpreter", _factory) - - agent = _make_agent(model_client=object()) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - - await agent._interpret_constraints(session, text="msg1", is_initial=True) - await agent._interpret_constraints(session, text="msg2", is_initial=False) - - for i, inst in enumerate(counter.instances): - assert len(inst.calls) == 1, f"Agent {i} should receive exactly 1 call" - assert ( - len(inst.calls[0]) == 1 - ), f"Agent {i} should receive exactly 1 message" - - -# --------------------------------------------------------------------------- -# Stage-gating fresh-agent-per-call tests -# --------------------------------------------------------------------------- - - -class TestBuildOneShotAgentStateless: - """_build_one_shot_agent must return a distinct object on every call.""" - - def test_returns_distinct_instance_per_call( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Each call to _build_one_shot_agent returns a new object.""" - instances: list[object] = [] - - class _TrackingAgent: - def __init__(self, **kwargs: Any) -> None: # noqa: ANN401 - instances.append(self) - - monkeypatch.setattr(agent_mod, "AssistantAgent", _TrackingAgent) - monkeypatch.setattr( - agent_mod, - "assert_strict_tools_for_structured_output", - lambda **_: None, - ) - - agent = _make_agent(model_client=object()) - from fateforger.agents.timeboxing.stage_gating import ( - COLLECT_CONSTRAINTS_PROMPT, - StageGateOutput, - ) - - first = agent._build_one_shot_agent( - "StageCollectConstraints", COLLECT_CONSTRAINTS_PROMPT, StageGateOutput - ) - second = agent._build_one_shot_agent( - "StageCollectConstraints", COLLECT_CONSTRAINTS_PROMPT, StageGateOutput - ) - - assert ( - first is not second - ), "_build_one_shot_agent must return a new instance on every call" - assert len(instances) == 2, f"Expected 2 instantiations, got {len(instances)}" - - def test_ten_sequential_calls_produce_ten_instances( - self, monkeypatch: pytest.MonkeyPatch - ) -> None: - """Simulates N Refine turns β€” each must produce a distinct agent.""" - instances: list[object] = [] - - class _TrackingAgent: - def __init__(self, **kwargs: Any) -> None: # noqa: ANN401 - instances.append(self) - - monkeypatch.setattr(agent_mod, "AssistantAgent", _TrackingAgent) - monkeypatch.setattr( - agent_mod, - "assert_strict_tools_for_structured_output", - lambda **_: None, - ) - - agent = _make_agent(model_client=object()) - from fateforger.agents.timeboxing.stage_gating import ( - COLLECT_CONSTRAINTS_PROMPT, - StageGateOutput, - ) - - for _ in range(10): - agent._build_one_shot_agent( - "StageCollectConstraints", COLLECT_CONSTRAINTS_PROMPT, StageGateOutput - ) - - assert len(instances) == 10, ( - f"10 Refine turns must produce 10 fresh agents, got {len(instances)}; " - "history accumulation bug detected." - ) - - -# --------------------------------------------------------------------------- -# No _ensure_* cached NLU attributes on the class -# --------------------------------------------------------------------------- - - -class TestNoLegacyCachedAgentAttributes: - """The agent must not carry any persistent NLU agent instance attributes. - - Presence of these attributes indicates the old caching pattern was - accidentally re-introduced, which would cause history accumulation. - """ - - _BANNED_ATTRS = ( - "_constraint_interpreter_agent", - "_planning_date_interpreter_agent", - "_memory_review_agent", - # stage agents (also removed) - "_stage_agents", - "_decision_agent", - "_summary_agent", - "_review_commit_agent", - ) - - def test_no_banned_cache_attributes_on_class(self) -> None: - """Banned cached-agent attributes must not appear on the class or its annotations.""" - for attr in self._BANNED_ATTRS: - assert not hasattr(TimeboxingFlowAgent, attr), ( - f"TimeboxingFlowAgent.{attr} is a banned cached-agent attribute. " - "Remove it and use fresh-per-call factory functions instead." - ) - annotations = getattr(TimeboxingFlowAgent, "__annotations__", {}) - assert ( - attr not in annotations - ), f"TimeboxingFlowAgent.__annotations__[{attr!r}] found β€” banned cached-agent attribute." - - def test_no_banned_ensure_methods_on_class(self) -> None: - """Banned _ensure_* caching wrapper methods must not exist on the class.""" - banned_methods = ( - "_ensure_planning_date_interpreter_agent", - "_ensure_memory_review_agent", - "_ensure_constraint_interpreter_agent", - ) - for method in banned_methods: - assert not hasattr(TimeboxingFlowAgent, method), ( - f"TimeboxingFlowAgent.{method} is a banned caching wrapper method. " - "Remove it and call the factory function directly per invocation." - ) - - def test_no_banned_attrs_in_init(self, monkeypatch: pytest.MonkeyPatch) -> None: - """__init__ must not set any banned cached-agent attributes.""" - # Patch external dependencies that would fail in unit-test context. - monkeypatch.setattr(agent_mod, "settings", _MinimalSettings()) - - class _DummyClient: - pass - - model_client = _DummyClient() - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - # Only inject the minimal required __init__ args. - try: - # We can't call full __init__ without a real runtime, but we can - # check the __init__ source does not assign the banned attrs. - import inspect - - source = inspect.getsource(TimeboxingFlowAgent.__init__) - for attr in self._BANNED_ATTRS: - assert f"self.{attr}" not in source, ( - f"TimeboxingFlowAgent.__init__ assigns self.{attr} β€” " - "banned cached-agent attribute." - ) - except Exception: - # If source inspection fails, fall through β€” the attribute tests above cover it. - pass - - -class _MinimalSettings: - """Stand-in for the global settings object in __init__ attribute scan.""" - - def __getattr__(self, name: str) -> Any: # noqa: ANN401 - return None - - -class _MinimalSettings: - """Stand-in for the global settings object in __init__ attribute scan.""" - - def __getattr__(self, name: str) -> Any: # noqa: ANN401 - return None diff --git a/tests/unit/timeboxing/test_timeboxing_submit_flow.py b/tests/unit/timeboxing/test_timeboxing_submit_flow.py deleted file mode 100644 index aa2a89fb..00000000 --- a/tests/unit/timeboxing/test_timeboxing_submit_flow.py +++ /dev/null @@ -1,484 +0,0 @@ -"""Unit tests for Stage 5 submit / cancel / undo session transitions.""" - -from __future__ import annotations - -from datetime import date, time, timedelta -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest - -pytest.importorskip("autogen_agentchat") - -from autogen_agentchat.messages import TextMessage - -from fateforger.agents.schedular.models.calendar import CalendarEvent, EventType -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.messages import ( - TimeboxingCancelSubmit, - TimeboxingConfirmSubmit, - TimeboxingUndoSubmit, -) -from fateforger.agents.timeboxing.stage_gating import TimeboxingStage -from fateforger.agents.timeboxing.sync_engine import SyncOp, SyncOpType, SyncTransaction -from fateforger.agents.timeboxing.tb_models import TBPlan -from fateforger.agents.timeboxing.timebox import ( - Timebox, - tb_plan_to_timebox, - timebox_to_tb_plan, -) -from fateforger.slack_bot.messages import SlackBlockMessage -from fateforger.slack_bot.timeboxing_submit import FF_TIMEBOX_UNDO_SUBMIT_ACTION_ID - - -class _Ctx: - topic_id = None - sender = None - - -def _build_plan(*, summary: str = "Focus") -> TBPlan: - """Return a minimal TBPlan for tests.""" - timebox = Timebox( - events=[ - CalendarEvent( - summary=summary, - event_type=EventType.DEEP_WORK, - start_time=time(9, 0), - duration=timedelta(minutes=90), - ) - ], - date=date(2026, 2, 13), - timezone="Europe/Amsterdam", - ) - return timebox_to_tb_plan(timebox) - - -def _build_submit_transaction() -> SyncTransaction: - """Return a committed create transaction for test assertions.""" - return SyncTransaction( - ops=[ - SyncOp( - op_type=SyncOpType.CREATE, - gcal_event_id="fftb123", - after_payload={ - "calendarId": "primary", - "eventId": "fftb123", - "summary": "Focus", - "start": "2026-02-13T09:00:00+01:00", - "end": "2026-02-13T10:30:00+01:00", - }, - ) - ], - status="committed", - ) - - -@pytest.mark.asyncio -async def test_confirm_submit_updates_session_and_returns_undo_button() -> None: - """Confirm submit should persist transaction state and include an Undo button.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._sessions = {} - agent._calendar_submitter = SimpleNamespace( - submit_plan=AsyncMock(return_value=_build_submit_transaction()), - ) - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.tz_name = "Europe/Amsterdam" - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = True - session.tb_plan = _build_plan() - session.base_snapshot = _build_plan(summary="Base") - session.event_id_map = {} - agent._sessions["t1"] = session - - result = await agent.on_confirm_submit( - TimeboxingConfirmSubmit(channel_id="c1", thread_ts="t1", user_id="u1"), - _Ctx(), - ) - - assert isinstance(result, SlackBlockMessage) - assert session.pending_submit is False - assert session.last_sync_transaction is not None - assert session.last_sync_event_id_map == {} - assert "Focus|09:00:00" in session.event_id_map - action_ids = [ - element.get("action_id") - for block in result.blocks - for element in block.get("elements", []) - ] - assert FF_TIMEBOX_UNDO_SUBMIT_ACTION_ID in action_ids - - -@pytest.mark.asyncio -async def test_confirm_submit_refreshes_remote_baseline_once( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Stage 5 submit should refresh remote baseline once after sync.""" - submit_plan = AsyncMock(return_value=_build_submit_transaction()) - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._sessions = {} - agent._calendar_submitter = SimpleNamespace(submit_plan=submit_plan) - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.tz_name = "Europe/Amsterdam" - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = True - session.tb_plan = _build_plan() - session.base_snapshot = _build_plan(summary="Base") - session.event_id_map = {} - session.remote_event_ids_by_index = [] - agent._sessions["t1"] = session - - refreshed = {"called": 0} - - async def _fake_refresh(self: TimeboxingFlowAgent, target: Session) -> None: - _ = self - refreshed["called"] += 1 - target.base_snapshot = _build_plan(summary=f"Remote-{refreshed['called']}") - target.remote_event_ids_by_index = ["fftb123"] - - monkeypatch.setattr( - TimeboxingFlowAgent, - "_refresh_remote_baseline_after_sync", - _fake_refresh, - ) - - result = await agent.on_confirm_submit( - TimeboxingConfirmSubmit(channel_id="c1", thread_ts="t1", user_id="u1"), - _Ctx(), - ) - - assert isinstance(result, SlackBlockMessage) - assert refreshed["called"] == 1 - assert submit_plan.await_count == 1 - assert submit_plan.await_args.kwargs["remote"].events[0].n == "Base" - assert session.base_snapshot is not None - assert session.base_snapshot.events[0].n == "Remote-1" - assert session.remote_event_ids_by_index == ["fftb123"] - - -@pytest.mark.asyncio -async def test_cancel_submit_returns_to_refine() -> None: - """Cancel submit should clear pending state and move session to refine.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._sessions = {} - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.tz_name = "Europe/Amsterdam" - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = True - agent._sessions["t1"] = session - - result = await agent.on_cancel_submit( - TimeboxingCancelSubmit(channel_id="c1", thread_ts="t1", user_id="u1"), - _Ctx(), - ) - - assert isinstance(result, TextMessage) - assert session.pending_submit is False - assert session.stage == TimeboxingStage.REFINE - - -@pytest.mark.asyncio -async def test_undo_submit_restores_snapshot_and_refine_stage() -> None: - """Undo should restore base snapshot, clear undo state, and return to refine.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._sessions = {} - submit_tx = _build_submit_transaction() - agent._calendar_submitter = SimpleNamespace( - undo_transaction=AsyncMock(return_value=SyncTransaction(status="undone")), - ) - - base_snapshot = _build_plan(summary="Base") - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.tz_name = "Europe/Amsterdam" - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = False - session.tb_plan = _build_plan(summary="Edited") - session.base_snapshot = base_snapshot - session.last_sync_transaction = submit_tx - session.last_sync_event_id_map = {"Base|09:00:00": "fftbbase"} - session.event_id_map = {"Focus|09:00:00": "fftb123"} - agent._sessions["t1"] = session - - result = await agent.on_undo_submit( - TimeboxingUndoSubmit(channel_id="c1", thread_ts="t1", user_id="u1"), - _Ctx(), - ) - - assert isinstance(result, SlackBlockMessage) - assert session.stage == TimeboxingStage.REFINE - assert session.last_sync_transaction is None - assert session.last_sync_event_id_map is None - assert session.event_id_map == {"Base|09:00:00": "fftbbase"} - assert session.tb_plan is not None - assert session.tb_plan.model_dump() == base_snapshot.model_dump() - - -@pytest.mark.asyncio -async def test_undo_submit_rejected_when_session_ended() -> None: - """Undo should be rejected when the session is already marked ended.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._sessions = {} - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.tz_name = "Europe/Amsterdam" - session.completed = True - session.last_sync_transaction = _build_submit_transaction() - agent._sessions["t1"] = session - - result = await agent.on_undo_submit( - TimeboxingUndoSubmit(channel_id="c1", thread_ts="t1", user_id="u1"), - _Ctx(), - ) - - assert isinstance(result, TextMessage) - assert "already ended" in result.content - - -@pytest.mark.asyncio -async def test_submit_current_plan_refreshes_remote_baseline( - monkeypatch: pytest.MonkeyPatch, -) -> None: - """Stage 4 submit should refresh remote baseline once after sync.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - submit_plan = AsyncMock(return_value=_build_submit_transaction()) - agent._calendar_submitter = SimpleNamespace(submit_plan=submit_plan) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.tz_name = "Europe/Amsterdam" - session.tb_plan = _build_plan(summary="Edited") - session.base_snapshot = _build_plan(summary="Base") - session.event_id_map = {} - session.remote_event_ids_by_index = [] - - refreshed = {"called": 0} - - async def _fake_refresh(self: TimeboxingFlowAgent, target: Session) -> None: - _ = self - refreshed["called"] += 1 - target.base_snapshot = _build_plan(summary=f"Remote-{refreshed['called']}") - target.remote_event_ids_by_index = ["fftb123"] - - monkeypatch.setattr( - TimeboxingFlowAgent, - "_refresh_remote_baseline_after_sync", - _fake_refresh, - ) - - outcome = await TimeboxingFlowAgent._submit_current_plan(agent, session) - - assert refreshed["called"] == 1 - assert submit_plan.await_count == 1 - assert submit_plan.await_args.kwargs["remote"].events[0].n == "Base" - assert outcome.status == "committed" - assert session.base_snapshot is not None - assert session.base_snapshot.events[0].n == "Remote-1" - assert session.remote_event_ids_by_index == ["fftb123"] - - -@pytest.mark.asyncio -async def test_submit_current_plan_aborts_when_remote_baseline_refresh_fails() -> None: - """Stage 4 submit should fail closed when latest baseline refresh fails.""" - submit_plan = AsyncMock(return_value=_build_submit_transaction()) - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._calendar_submitter = SimpleNamespace(submit_plan=submit_plan) - agent._session_debug = lambda *_args, **_kwargs: None - agent._refresh_remote_baseline_before_submit = AsyncMock(return_value=False) - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.tz_name = "Europe/Amsterdam" - session.tb_plan = _build_plan(summary="Edited") - session.base_snapshot = _build_plan(summary="Base") - session.event_id_map = {} - session.remote_event_ids_by_index = [] - session.planned_date = "2026-02-13" - - outcome = await TimeboxingFlowAgent._submit_current_plan(agent, session) - - assert outcome.status == "skipped" - assert outcome.changed is False - assert "couldn't refresh" in outcome.note.lower() - submit_plan.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_refine_patch_path_stages_locally_without_remote_submit() -> None: - """Stage 4 patching should keep edits local and defer remote writes to Stage 5 submit.""" - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._collect_constraints = AsyncMock(return_value=[]) - agent._submit_current_plan = AsyncMock(return_value=None) - patched_plan = _build_plan(summary="Patched Focus") - - async def _apply_patch(**kwargs): - kwargs["plan_validator"](patched_plan) - return patched_plan, object() - - agent._timebox_patcher = SimpleNamespace( - apply_patch=AsyncMock(side_effect=_apply_patch) - ) - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.tb_plan = _build_plan(summary="Original Focus") - session.timebox = tb_plan_to_timebox(session.tb_plan) - - outcome = await TimeboxingFlowAgent._execute_refine_patch_and_sync( - agent, - session=session, - patch_message="Shift deep work later.", - ) - - assert outcome.status == "staged" - assert outcome.changed is True - assert "Review Stage 5" in outcome.note - assert session.tb_plan is patched_plan - assert session.timebox is not None - assert session.timebox.events[0].summary == "Patched Focus" - agent._submit_current_plan.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_submit_pending_plan_resubmit_no_delta_returns_noop_success() -> None: - """Re-submit with no material delta should succeed as explicit no-op.""" - submit_plan = AsyncMock(return_value=_build_submit_transaction()) - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._calendar_submitter = SimpleNamespace(submit_plan=submit_plan) - debug_events: list[tuple[str, dict]] = [] - agent._session_debug = ( - lambda _session, event, **kwargs: debug_events.append((event, kwargs)) - ) - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.tz_name = "Europe/Amsterdam" - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = True - session.tb_plan = _build_plan(summary="Noop") - session.base_snapshot = _build_plan(summary="Noop") - session.event_id_map = {} - session.remote_event_ids_by_index = [] - session.last_sync_transaction = _build_submit_transaction() - - result = await TimeboxingFlowAgent._submit_pending_plan( - agent, - session=session, - submit_mode="auto_nl", - submit_attempt_kind="resubmit", - ) - - assert isinstance(result, SlackBlockMessage) - assert "already up to date" in result.text - assert "Reconciliation:" in result.text - assert "remote fetched" in result.text - assert session.pending_submit is False - submit_plan.assert_not_awaited() - result_events = [payload for name, payload in debug_events if name == "submission_result"] - assert result_events - assert result_events[-1]["submit_mode"] == "auto_nl" - assert result_events[-1]["submit_attempt_kind"] == "resubmit" - assert result_events[-1]["no_material_delta"] is True - assert result_events[-1]["planned_create"] == 0 - assert result_events[-1]["planned_update"] == 0 - assert result_events[-1]["planned_delete"] == 0 - - -@pytest.mark.asyncio -async def test_submit_pending_plan_aborts_when_remote_baseline_refresh_fails() -> None: - """Submit must fail closed when latest remote baseline cannot be refreshed.""" - submit_plan = AsyncMock(return_value=_build_submit_transaction()) - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._calendar_submitter = SimpleNamespace(submit_plan=submit_plan) - agent._session_debug = lambda *_args, **_kwargs: None - agent._refresh_remote_baseline_before_submit = AsyncMock(return_value=False) - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.tz_name = "Europe/Amsterdam" - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = True - session.tb_plan = _build_plan(summary="Edited") - session.base_snapshot = _build_plan(summary="Base") - session.event_id_map = {} - session.remote_event_ids_by_index = [] - session.last_sync_transaction = _build_submit_transaction() - session.planned_date = "2026-02-13" - - result = await TimeboxingFlowAgent._submit_pending_plan( - agent, - session=session, - submit_mode="manual_button", - submit_attempt_kind="resubmit", - ) - - assert isinstance(result, TextMessage) - assert "did not submit" in result.content.lower() - assert session.pending_submit is True - submit_plan.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_submit_pending_plan_aborts_when_base_snapshot_missing_after_refresh() -> None: - """Submit should stop when refresh claims success but baseline snapshot is absent.""" - submit_plan = AsyncMock(return_value=_build_submit_transaction()) - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._calendar_submitter = SimpleNamespace(submit_plan=submit_plan) - agent._session_debug = lambda *_args, **_kwargs: None - agent._refresh_remote_baseline_before_submit = AsyncMock(return_value=True) - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.tz_name = "Europe/Amsterdam" - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = True - session.tb_plan = _build_plan(summary="Edited") - session.base_snapshot = None - session.event_id_map = {} - session.remote_event_ids_by_index = [] - session.planned_date = "2026-02-13" - - result = await TimeboxingFlowAgent._submit_pending_plan( - agent, - session=session, - submit_mode="manual_button", - submit_attempt_kind="resubmit", - ) - - assert isinstance(result, TextMessage) - assert "incomplete" in result.content.lower() - assert session.pending_submit is False - submit_plan.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_submit_pending_plan_committed_includes_reconciliation_summary() -> None: - """Successful submit should include deterministic reconciliation counts.""" - submit_plan = AsyncMock(return_value=_build_submit_transaction()) - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._calendar_submitter = SimpleNamespace(submit_plan=submit_plan) - debug_events: list[tuple[str, dict]] = [] - agent._session_debug = ( - lambda _session, event, **kwargs: debug_events.append((event, kwargs)) - ) - - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.tz_name = "Europe/Amsterdam" - session.stage = TimeboxingStage.REVIEW_COMMIT - session.pending_submit = True - session.tb_plan = _build_plan(summary="Focus") - session.base_snapshot = _build_plan(summary="Base") - session.event_id_map = {} - session.remote_event_ids_by_index = [] - - result = await TimeboxingFlowAgent._submit_pending_plan( - agent, - session=session, - submit_mode="manual_button", - submit_attempt_kind="first_submit", - ) - - assert isinstance(result, SlackBlockMessage) - assert "Reconciliation:" in result.text - assert "remote fetched" in result.text - assert "create" in result.text - assert session.pending_submit is False - submit_plan.assert_awaited_once() - result_events = [payload for name, payload in debug_events if name == "submission_result"] - assert result_events - assert result_events[-1]["planned_create"] >= 0 - assert result_events[-1]["planned_update"] >= 0 - assert result_events[-1]["planned_delete"] >= 0 diff --git a/tests/unit/timeboxing/test_timeboxing_task_marshalling_capability.py b/tests/unit/timeboxing/test_timeboxing_task_marshalling_capability.py deleted file mode 100644 index 07fe06fe..00000000 --- a/tests/unit/timeboxing/test_timeboxing_task_marshalling_capability.py +++ /dev/null @@ -1,211 +0,0 @@ -from __future__ import annotations - -import asyncio -import logging - -import pytest - -pytest.importorskip("autogen_core") -pytest.importorskip("autogen_agentchat") - -from autogen_agentchat.messages import TextMessage - -from fateforger.agents.tasks.messages import PendingTaskItem, PendingTaskSnapshot -from fateforger.agents.timeboxing.contracts import TaskCandidate -from fateforger.agents.timeboxing.task_marshalling_capability import ( - TaskAssistRequest, - TaskMarshallingCapability, -) - - -class _Session: - def __init__(self) -> None: - self.user_id = "u1" - self.channel_id = "c1" - self.thread_ts = "t1" - self.session_key = "k1" - self.input_facts: dict = {} - self.prefetched_pending_tasks: list[TaskCandidate] = [] - self.pending_tasks_prefetch = False - - -def _cap(send): - return TaskMarshallingCapability( - send_message=send, - timeout_s=3.0, - source_resolver=lambda: "timeboxing_agent", - ) - - -def test_assist_request_text_message_is_typed_and_deterministic() -> None: - assert ( - TaskAssistRequest(user_message="show pending tasks", note=None).to_text_message() - == "show pending tasks" - ) - assert ( - TaskAssistRequest( - user_message="show pending tasks", - note="request came from assist flow", - ).to_text_message() - == "show pending tasks\n\nAssist context: request came from assist flow" - ) - - -def test_merge_prefetched_tasks_respects_existing_user_tasks() -> None: - merged = TaskMarshallingCapability.merge_prefetched_tasks( - input_facts={"tasks": [{"title": "User task"}]}, - prefetched=[TaskCandidate(title="Prefetched task")], - ) - assert merged["tasks"][0]["title"] == "User task" - - -@pytest.mark.asyncio -async def test_request_pending_tasks_uses_snapshot_and_returns_candidates() -> None: - async def _send(message, recipient, cancellation_token): # noqa: ARG001 - assert recipient.type == "tasks_agent" - return PendingTaskSnapshot( - items=[PendingTaskItem(id="1", title="Write PR notes")], - summary="Found 1 pending task(s).", - ) - - session = _Session() - tasks = await _cap(_send).request_pending_tasks( - session=session, - query="pending", - limit=10, - ) - assert [task.title for task in tasks] == ["Write PR notes"] - - -@pytest.mark.asyncio -async def test_request_pending_tasks_logs_invalid_payload_and_returns_empty(caplog) -> None: - async def _send(message, recipient, cancellation_token): # noqa: ARG001 - return {"items": "not-a-list"} - - caplog.set_level(logging.WARNING) - session = _Session() - tasks = await _cap(_send).request_pending_tasks(session=session) - - assert tasks == [] - assert any( - "task_marshalling_pending_snapshot_invalid_payload" - == getattr(record, "event", "") - for record in caplog.records - ) - - -@pytest.mark.asyncio -async def test_assist_tasks_forwards_generic_task_query() -> None: - async def _send(message, recipient, cancellation_token): # noqa: ARG001 - assert recipient.type == "tasks_agent" - assert isinstance(message, TextMessage) - assert message.content == "help me triage tasks\n\nAssist context: adjacent question" - return TextMessage(content="Task Marshal response", source="tasks_agent") - - session = _Session() - out = await _cap(_send).assist_response( - session=session, - user_message="help me triage tasks", - note="adjacent question", - ) - assert out == "Task Marshal response" - - -@pytest.mark.asyncio -async def test_assist_returns_none_for_empty_user_message() -> None: - async def _send(message, recipient, cancellation_token): # noqa: ARG001 - raise AssertionError("send_message should not be called") - - session = _Session() - out = await _cap(_send).assist_response( - session=session, - user_message=" ", - note="assist request", - ) - assert out is None - - -@pytest.mark.asyncio -async def test_assist_routes_notion_sprint_query_to_tasks_agent() -> None: - async def _send(message, recipient, cancellation_token): # noqa: ARG001 - assert recipient.type == "tasks_agent" - assert isinstance(message, TextMessage) - assert message.content == ( - "show pending sprint tickets in notion\n\nAssist context: assist request" - ) - return TextMessage(content="Sprint query handled", source="tasks_agent") - - session = _Session() - out = await _cap(_send).assist_response( - session=session, - user_message="show pending sprint tickets in notion", - note="assist request", - ) - assert out == "Sprint query handled" - - -@pytest.mark.asyncio -async def test_assist_timeout_returns_deterministic_non_fatal_message() -> None: - async def _send(message, recipient, cancellation_token): # noqa: ARG001 - await asyncio.sleep(0.05) - return TextMessage(content="late", source="tasks_agent") - - cap = TaskMarshallingCapability( - send_message=_send, - timeout_s=0.001, - source_resolver=lambda: "timeboxing_agent", - ) - session = _Session() - out = await cap.assist_response( - session=session, - user_message="help me triage tasks", - note="adjacent question", - ) - assert out is None - - -@pytest.mark.asyncio -async def test_assist_exception_returns_deterministic_non_fatal_message() -> None: - async def _send(message, recipient, cancellation_token): # noqa: ARG001 - raise RuntimeError("boom") - - session = _Session() - out = await _cap(_send).assist_response( - session=session, - user_message="help me triage tasks", - note="adjacent question", - ) - assert out is None - - -@pytest.mark.asyncio -async def test_queue_prefetch_dedupes_back_to_back_calls() -> None: - calls = 0 - updates: list[str] = [] - - async def _send(message, recipient, cancellation_token): # noqa: ARG001 - nonlocal calls - calls += 1 - await asyncio.sleep(0.02) - return PendingTaskSnapshot( - items=[PendingTaskItem(id="1", title="Do taxes")], - summary="Found 1 pending task(s).", - ) - - cap = _cap(_send) - session = _Session() - cap.queue_prefetch( - session=session, - reason="prefetch", - append_background_update=lambda _session, update: updates.append(update), - ) - cap.queue_prefetch( - session=session, - reason="prefetch", - append_background_update=lambda _session, update: updates.append(update), - ) - - await asyncio.sleep(0.06) - assert calls == 1 - assert session.pending_tasks_prefetch is False - assert updates == ["Loaded 1 pending task candidate(s) from task-marshalling (prefetch)."] diff --git a/tests/unit/timeboxing/test_timeboxing_task_prefetch_context.py b/tests/unit/timeboxing/test_timeboxing_task_prefetch_context.py deleted file mode 100644 index 7bccca07..00000000 --- a/tests/unit/timeboxing/test_timeboxing_task_prefetch_context.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import annotations - -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.contracts import TaskCandidate -from fateforger.agents.timeboxing.preferences import ( - Constraint, - ConstraintNecessity, - ConstraintScope, -) - - -def test_capture_inputs_context_injects_prefetched_tasks_when_input_missing() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.prefetched_pending_tasks = [ - TaskCandidate(title="Write weekly report", block_count=1), - TaskCandidate(title="Reply to investor email"), - ] - session.input_facts = {"daily_one_thing": {"title": "Ship patch"}} - - context = agent._build_capture_inputs_context(session, user_message="") # noqa: SLF001 - tasks = context["input_facts"]["tasks"] - - assert len(tasks) == 2 - assert tasks[0]["title"] == "Write weekly report" - assert tasks[1]["title"] == "Reply to investor email" - - -def test_capture_inputs_context_keeps_existing_tasks_over_prefetch() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.prefetched_pending_tasks = [TaskCandidate(title="Should not replace")] - session.input_facts = {"tasks": [{"title": "User supplied task", "block_count": 2}]} - - context = agent._build_capture_inputs_context(session, user_message="") # noqa: SLF001 - tasks = context["input_facts"]["tasks"] - - assert len(tasks) == 1 - assert tasks[0]["title"] == "User supplied task" - - -def _session_scope_constraint(*, aspect_id: str, name: str = "Scope signal") -> Constraint: - return Constraint( - user_id="u1", - channel_id="c1", - thread_ts="t1", - name=name, - description=name, - necessity=ConstraintNecessity.MUST, - scope=ConstraintScope.SESSION, - hints={"aspect_classification": {"aspect_id": aspect_id}}, - ) - - -def test_capture_inputs_context_suppresses_prefetch_when_gtd_admin_exclusion_active() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.prefetched_pending_tasks = [ - TaskCandidate(title="Factuur van Coolblue"), - TaskCandidate(title="Review my Next Actions list"), - ] - session.active_constraints = [ - _session_scope_constraint( - aspect_id="gtd_admin_exclusion", name="Exclude GTD/Admin" - ) - ] - - context = agent._build_capture_inputs_context(session, user_message="") # noqa: SLF001 - - assert context["input_facts"].get("tasks") in (None, []) - - -def test_capture_inputs_context_suppresses_prefetch_when_daily_one_thing_active() -> None: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - session = Session(thread_ts="t1", channel_id="c1", user_id="u1") - session.prefetched_pending_tasks = [ - TaskCandidate(title="Generic admin task"), - TaskCandidate(title="Inbox cleanup"), - ] - session.active_constraints = [ - _session_scope_constraint(aspect_id="daily_one_thing", name="Daily One Thing") - ] - - context = agent._build_capture_inputs_context(session, user_message="") # noqa: SLF001 - - assert context["input_facts"].get("tasks") in (None, []) diff --git a/tests/unit/timeboxing/test_timeboxing_tb_plan_conversion.py b/tests/unit/timeboxing/test_timeboxing_tb_plan_conversion.py deleted file mode 100644 index 103ffce2..00000000 --- a/tests/unit/timeboxing/test_timeboxing_tb_plan_conversion.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Regression tests for TBPlan conversion and CalendarEvent typing.""" - -from __future__ import annotations - -from datetime import date, datetime, time, timedelta - -import pytest - -pytest.importorskip("autogen_agentchat") - -from fateforger.agents.schedular.models.calendar import CalendarEvent, EventType -from fateforger.agents.timeboxing.tb_models import ET, FixedWindow, TBEvent, TBPlan -from fateforger.agents.timeboxing.timebox import Timebox, tb_plan_to_timebox, timebox_to_tb_plan - - -def test_calendar_event_color_id_accepts_string_event_type() -> None: - """CalendarEvent should coerce string event_type values into EventType enums.""" - event = CalendarEvent( - summary="Focus", - event_type=EventType.DEEP_WORK.value, - start_time=time(9, 0), - end_time=time(11, 0), - ) - - assert event.event_type == EventType.DEEP_WORK - assert event.colorId == EventType.DEEP_WORK.color_id - - -def test_tb_plan_to_timebox_converts_fixed_windows_without_validation_error() -> None: - """TBPlan fixed-window events should convert to Timebox cleanly.""" - plan = TBPlan( - date=date(2026, 2, 14), - tz="Europe/Amsterdam", - events=[ - TBEvent( - n="Deep Work", - t=ET.DW, - p=FixedWindow(st=time(10, 0), et=time(12, 0)), - ) - ], - ) - - timebox = tb_plan_to_timebox(plan) - assert len(timebox.events) == 1 - assert timebox.events[0].summary == "Deep Work" - assert timebox.events[0].start_time == time(10, 0) - assert timebox.events[0].end_time == time(12, 0) - - -def test_timebox_to_tb_plan_recovers_missing_event_summary() -> None: - """Conversion should not crash when legacy/malformed events omit summary.""" - malformed_event = CalendarEvent.model_construct( - summary=None, - event_type=EventType.MEETING, - start_time=time(9, 0), - end_time=time(10, 0), - eventId="evt_123", - ) - timebox = Timebox.model_construct( - events=[malformed_event], - date=date(2026, 2, 14), - timezone="Europe/Amsterdam", - ) - - plan = timebox_to_tb_plan(timebox) - - assert len(plan.events) == 1 - assert plan.events[0].n == "evt_123" - - -def test_timebox_to_tb_plan_uses_datetime_anchors_as_fixed_window() -> None: - """Conversion should map datetime start/end anchors into FixedWindow timing.""" - anchored_event = CalendarEvent.model_construct( - summary="Calendar Busy", - event_type=EventType.MEETING, - start=datetime(2026, 2, 14, 9, 0), - end=datetime(2026, 2, 14, 10, 0), - start_time=None, - end_time=None, - duration=None, - ) - timebox = Timebox.model_construct( - events=[anchored_event], - date=date(2026, 2, 14), - timezone="Europe/Amsterdam", - ) - - plan = timebox_to_tb_plan(timebox) - assert len(plan.events) == 1 - assert isinstance(plan.events[0].p, FixedWindow) - assert plan.events[0].p.st == time(9, 0) - assert plan.events[0].p.et == time(10, 0) - - -def test_timebox_to_tb_plan_validate_false_keeps_unanchored_seed() -> None: - """`validate=False` should preserve an editable seed for Stage 4 repair.""" - unanchored = CalendarEvent.model_construct( - summary="Unanchored", - event_type=EventType.MEETING, - start_time=None, - end_time=None, - duration=timedelta(minutes=45), - ) - timebox = Timebox.model_construct( - events=[unanchored], - date=date(2026, 2, 14), - timezone="Europe/Amsterdam", - ) - - with pytest.raises(ValueError, match="fixed_start or fixed_window anchor"): - timebox_to_tb_plan(timebox) - - seed = timebox_to_tb_plan(timebox, validate=False) - assert len(seed.events) == 1 - assert seed.events[0].p.a == "ap" diff --git a/tests/unit/timeboxing/test_timeboxing_thread_reply_kickoff.py b/tests/unit/timeboxing/test_timeboxing_thread_reply_kickoff.py deleted file mode 100644 index d49179c5..00000000 --- a/tests/unit/timeboxing/test_timeboxing_thread_reply_kickoff.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Unit tests for natural-language thread reply kickoff in timeboxing.""" - -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest - -pytest.importorskip("autogen_agentchat") - -from autogen_agentchat.messages import TextMessage - -from fateforger.agents.timeboxing.agent import Session, TimeboxingFlowAgent -from fateforger.agents.timeboxing.messages import TimeboxingUserReply -from fateforger.agents.timeboxing.stage_gating import TimeboxingStage - - -class _Ctx: - topic_id = None - sender = None - - -def _build_agent() -> TimeboxingFlowAgent: - agent = TimeboxingFlowAgent.__new__(TimeboxingFlowAgent) - agent._sessions = {} - agent._session_debug = lambda *_args, **_kwargs: None # type: ignore[attr-defined] - agent._default_tz_name = lambda: "Europe/Amsterdam" # type: ignore[attr-defined] - agent._interpret_planned_date = AsyncMock(return_value="2026-02-28") # type: ignore[attr-defined] - agent._prefetch_calendar_immovables = AsyncMock() # type: ignore[attr-defined] - agent._queue_constraint_prefetch = lambda *_args, **_kwargs: None # type: ignore[attr-defined] - agent._await_pending_durable_constraint_prefetch = AsyncMock() # type: ignore[attr-defined] - agent._ensure_calendar_immovables = AsyncMock() # type: ignore[attr-defined] - agent._is_collect_stage_loaded = lambda *_args, **_kwargs: True # type: ignore[attr-defined] - agent._run_graph_turn = AsyncMock( - return_value=TextMessage(content="stage advanced", source="timeboxing_agent") - ) # type: ignore[attr-defined] - agent._maybe_wrap_constraint_review = AsyncMock( - side_effect=lambda *, reply, session: reply - ) # type: ignore[attr-defined] - agent._attach_presenter_blocks = lambda *, reply, session: reply # type: ignore[attr-defined] - agent._publish_update = AsyncMock() # type: ignore[attr-defined] - agent._reset_durable_prefetch_state = lambda *_args, **_kwargs: None # type: ignore[attr-defined] - return agent - - -@pytest.mark.asyncio -async def test_missing_session_thread_reply_commits_and_advances() -> None: - """Missing session in-thread should commit implicitly and run stage flow.""" - agent = _build_agent() - agent._build_commit_prompt_blocks = lambda **_kwargs: (_ for _ in ()).throw( # type: ignore[attr-defined] - AssertionError("Should not return Stage-0 commit prompt for in-thread NL kickoff.") - ) - - result = await agent.on_user_reply( - TimeboxingUserReply( - channel_id="C1", - thread_ts="T1", - user_id="U1", - text="Today. Wake 12:00, focused work day.", - ), - _Ctx(), - ) - - assert isinstance(result, TextMessage) - session = agent._sessions["T1"] - assert session.committed is True - assert session.stage == TimeboxingStage.COLLECT_CONSTRAINTS - agent._run_graph_turn.assert_awaited_once() # type: ignore[attr-defined] - - -@pytest.mark.asyncio -async def test_existing_uncommitted_thread_reply_commits_and_advances() -> None: - """Existing uncommitted session should commit implicitly and proceed in same turn.""" - agent = _build_agent() - session = Session(thread_ts="T1", channel_id="C1", user_id="U1") - session.committed = False - session.stage = TimeboxingStage.COLLECT_CONSTRAINTS - session.planned_date = "2026-02-27" - session.tz_name = "Europe/Amsterdam" - agent._sessions["T1"] = session - - result = await agent.on_user_reply( - TimeboxingUserReply( - channel_id="C1", - thread_ts="T1", - user_id="U1", - text="Actually let's do tomorrow.", - ), - _Ctx(), - ) - - assert isinstance(result, TextMessage) - assert session.committed is True - assert session.planned_date == "2026-02-28" - agent._run_graph_turn.assert_awaited_once() # type: ignore[attr-defined] diff --git a/tests/unit/timeboxing/test_timeboxing_tool_result_presenter.py b/tests/unit/timeboxing/test_timeboxing_tool_result_presenter.py deleted file mode 100644 index ea65e01b..00000000 --- a/tests/unit/timeboxing/test_timeboxing_tool_result_presenter.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -from fateforger.agents.timeboxing.tool_result_models import ( - InteractionMode, - MemoryConstraintItem, - MemoryToolResult, -) -from fateforger.agents.timeboxing.tool_result_presenter import ( - InteractionContext, - present_memory_tool_result, -) - - -def test_present_memory_tool_result_text_mode() -> None: - result = MemoryToolResult(action="list", ok=True, message="Memory updated") - presentation = present_memory_tool_result( - result=result, - context=InteractionContext( - mode=InteractionMode.TEXT, - user_id="u_1", - thread_ts="t_1", - ), - ) - assert presentation.payload["action"] == "list" - assert presentation.blocks == [] - assert presentation.text_update == "Memory updated" - - -def test_present_memory_tool_result_slack_mode() -> None: - result = MemoryToolResult( - action="get", - ok=True, - message="Found 1 constraint.", - constraints=[ - MemoryConstraintItem( - uid="tb_1", - name="Protect mornings", - description="No meetings before noon", - status="locked", - scope="profile", - source="user", - used_this_session=True, - needs_confirmation=True, - ) - ], - ) - presentation = present_memory_tool_result( - result=result, - context=InteractionContext( - mode=InteractionMode.SLACK, - user_id="u_1", - thread_ts="t_1", - ), - ) - assert presentation.payload["action"] == "get" - assert presentation.text_update is None - assert presentation.blocks - assert any("Memory" in block.get("text", {}).get("text", "") for block in presentation.blocks) - block_texts = [ - block.get("text", {}).get("text", "") - for block in presentation.blocks - if isinstance(block, dict) and isinstance(block.get("text"), dict) - ] - assert any("source: user" in text for text in block_texts) - assert any("used: this session" in text for text in block_texts) diff --git a/tests/unit/tmbx/test_constraint_refs.py b/tests/unit/tmbx/test_constraint_refs.py deleted file mode 100644 index a916f8fd..00000000 --- a/tests/unit/tmbx/test_constraint_refs.py +++ /dev/null @@ -1,43 +0,0 @@ -from __future__ import annotations - -from types import SimpleNamespace - -from tmbx.journal.constraint_refs import constraint_refs - - -def _c(hints=None, name="Dinner", description="at 18:30", necessity="must", scope="profile"): - return SimpleNamespace( - hints=hints if hints is not None else {}, - name=name, - description=description, - necessity=necessity, - scope=scope, - ) - - -def test_minted_uid_is_used_and_tagged(): - refs = constraint_refs([_c(hints={"uid": "abc123"})]) - assert refs[0].uid == "abc123" - assert refs[0].uid_kind == "minted" - - -def test_missing_uid_is_unresolvable_not_invented(): - """No hints["uid"] means no identity claim is made β€” nothing is derived - from the constraint's text (CLAUDE.md bans content-derived identity).""" - refs = constraint_refs([_c()]) - assert refs[0].uid_kind == "unresolvable" - assert refs[0].uid == "" - - -def test_reason_is_carried_through(): - refs = constraint_refs([_c(hints={"uid": "x", "extraction_reason": "graphflow_turn"})]) - assert refs[0].reason == "graphflow_turn" - - -def test_missing_reason_is_none_not_guessed(): - refs = constraint_refs([_c(hints={"uid": "x"})]) - assert refs[0].reason is None - - -def test_empty_input(): - assert constraint_refs([]) == [] diff --git a/tests/unit/tmbx/test_instrument.py b/tests/unit/tmbx/test_instrument.py deleted file mode 100644 index f8963059..00000000 --- a/tests/unit/tmbx/test_instrument.py +++ /dev/null @@ -1,382 +0,0 @@ -# tests/unit/tmbx/test_instrument.py -from __future__ import annotations - -from datetime import date -from types import SimpleNamespace - -import pytest - -from tmbx.journal.instrument import ( - UNRESOLVED_CALENDAR_ID, - JournalingPatcher, - JournalingSubmitter, -) -from tmbx.journal.models import EntryKind, PatchOutcome -from tmbx.journal.store import JournalStore, init_journal - -DAY = date(2026, 8, 17) - - -@pytest.fixture -async def store(tmp_path): - return JournalStore(await init_journal(tmp_path / "j.db")) - - -class _FakePatcher: - def __init__(self, *, raises: Exception | None = None): - self.raises = raises - - async def apply_patch(self, **kwargs): - if self.raises: - raise self.raises - plan = SimpleNamespace(date=DAY) - patch = SimpleNamespace(model_dump_json=lambda: '{"ops":[{"op":"ue"}]}') - return plan, patch - - async def apply_patch_legacy(self, **kwargs): - if self.raises: - raise self.raises - # Legacy interface returns a Timebox-like object directly, not a - # (plan, patch) tuple. - return SimpleNamespace(date=DAY) - - -class _FakeSubmitter: - def __init__(self): - self.last_transaction = None - - async def submit_plan(self, desired, **kwargs): - return SimpleNamespace(status="committed", ops=[], results=[]) - - async def undo_transaction(self, tx): - # "undone" is the real success status undo_sync sets (sync_engine.py:524); - # a successful commit's status is "committed", but that string is never - # reused for a successful undo. - return SimpleNamespace(status="undone", ops=[], results=[]) - - -async def test_successful_patch_writes_attempt_row(store): - patcher = JournalingPatcher(_FakePatcher(), store, calendar_id_fn=lambda: "primary") - await patcher.apply_patch( - stage="Refine", - current=SimpleNamespace(date=DAY), - user_message="move lunch", - constraints=[SimpleNamespace(hints={"uid": "c1", "extraction_reason": "graphflow_turn"})], - ) - - rows = await store.by_day("primary", DAY) - assert len(rows) == 1 - assert rows[0].kind is EntryKind.ATTEMPT - assert rows[0].outcome is PatchOutcome.APPLIED - assert rows[0].instruction == "move lunch" - assert rows[0].get_constraints()[0].uid == "c1" - assert rows[0].get_constraints()[0].reason == "graphflow_turn" - - -async def test_failed_patch_writes_failure_row_and_reraises(store): - patcher = JournalingPatcher( - _FakePatcher(raises=ValueError("bad patch")), store, calendar_id_fn=lambda: "primary" - ) - with pytest.raises(ValueError): - await patcher.apply_patch( - stage="Refine", current=SimpleNamespace(date=DAY), user_message="x", constraints=[] - ) - - rows = await store.by_day("primary", DAY) - assert rows[0].outcome is PatchOutcome.APPLY_FAILED - assert "bad patch" in (rows[0].error or "") - - -async def test_journal_failure_never_breaks_planning(store): - class _BrokenStore: - async def append(self, entry): - raise RuntimeError("disk full") - - patcher = JournalingPatcher(_FakePatcher(), _BrokenStore(), calendar_id_fn=lambda: "primary") - plan, patch = await patcher.apply_patch( - stage="Refine", current=SimpleNamespace(date=DAY), user_message="x", constraints=[] - ) - assert plan is not None - - -async def test_submit_writes_commit_row_with_tx_id(store): - sub = JournalingSubmitter(_FakeSubmitter(), store) - tx = await sub.submit_plan(SimpleNamespace(date=DAY), calendar_id="primary") - - rows = await store.by_day("primary", DAY) - assert rows[0].kind is EntryKind.COMMIT - assert rows[0].tx_id is not None - assert getattr(tx, "tmbx_tx_id", None) == rows[0].tx_id - - -async def test_undo_row_references_the_commit(store): - sub = JournalingSubmitter(_FakeSubmitter(), store) - tx = await sub.submit_plan(SimpleNamespace(date=DAY), calendar_id="primary") - await sub.undo_transaction(tx) - - rows = await store.by_day("primary", DAY) - assert rows[1].kind is EntryKind.UNDO - assert rows[1].undoes_tx == rows[0].tx_id - assert rows[1].outcome is PatchOutcome.APPLIED - - -# ── Important 1: constraint_refs() must never break planning ────────────── - - -async def test_broken_constraint_refs_never_breaks_planning(store): - """A constraint whose attribute access raises (e.g. a detached ORM - instance) must not stop apply_patch from returning its result.""" - - class _ExplodingConstraint: - @property - def hints(self): - raise RuntimeError("DetachedInstanceError") - - patcher = JournalingPatcher(_FakePatcher(), store, calendar_id_fn=lambda: "primary") - plan, patch = await patcher.apply_patch( - stage="Refine", - current=SimpleNamespace(date=DAY), - user_message="x", - constraints=[_ExplodingConstraint()], - ) - assert plan is not None - - rows = await store.by_day("primary", DAY) - assert len(rows) == 1 - assert rows[0].get_constraints() == [] - - -# ── Important 2: partial commits/undos must not be journaled as APPLIED ─── - - -async def test_partial_submit_is_not_journaled_as_applied(store): - class _PartialSubmitter: - async def submit_plan(self, desired, **kwargs): - return SimpleNamespace(status="partial_halted", ops=[], results=[]) - - sub = JournalingSubmitter(_PartialSubmitter(), store) - await sub.submit_plan(SimpleNamespace(date=DAY), calendar_id="primary") - - rows = await store.by_day("primary", DAY) - assert rows[0].kind is EntryKind.COMMIT - assert rows[0].outcome is not PatchOutcome.APPLIED - assert rows[0].outcome is PatchOutcome.APPLY_FAILED - - -async def test_partial_undo_is_not_journaled_as_applied(store): - class _PartialUndoSubmitter: - async def submit_plan(self, desired, **kwargs): - return SimpleNamespace(status="committed", ops=[], results=[]) - - async def undo_transaction(self, tx): - return SimpleNamespace(status="undo_partial", ops=[], results=[]) - - sub = JournalingSubmitter(_PartialUndoSubmitter(), store) - tx = await sub.submit_plan(SimpleNamespace(date=DAY), calendar_id="primary") - await sub.undo_transaction(tx) - - rows = await store.by_day("primary", DAY) - assert rows[1].kind is EntryKind.UNDO - assert rows[1].outcome is not PatchOutcome.APPLIED - assert rows[1].outcome is PatchOutcome.APPLY_FAILED - - -# ── Important 3: calendar_id_fn is resolved per call, not fixed at init ─── - - -async def test_calendar_id_fn_is_resolved_per_call(store): - ids = iter(["cal-a", "cal-b"]) - patcher = JournalingPatcher(_FakePatcher(), store, calendar_id_fn=lambda: next(ids)) - await patcher.apply_patch( - stage="Refine", current=SimpleNamespace(date=DAY), user_message="first", constraints=[] - ) - await patcher.apply_patch( - stage="Refine", current=SimpleNamespace(date=DAY), user_message="second", constraints=[] - ) - - rows_a = await store.by_day("cal-a", DAY) - rows_b = await store.by_day("cal-b", DAY) - assert len(rows_a) == 1 and rows_a[0].instruction == "first" - assert len(rows_b) == 1 and rows_b[0].instruction == "second" - - -async def test_default_calendar_id_fn_records_unresolved_not_primary(store): - """With no resolver supplied, the row must not claim "primary" β€” that is - an invented value, not a real fact about which calendar the session - concerns. It must be recorded as unresolved instead.""" - patcher = JournalingPatcher(_FakePatcher(), store) - await patcher.apply_patch( - stage="Refine", current=SimpleNamespace(date=DAY), user_message="x", constraints=[] - ) - - assert await store.by_day("primary", DAY) == [] - - rows = await store.by_day(UNRESOLVED_CALENDAR_ID, DAY) - assert len(rows) == 1 - assert rows[0].calendar_id == UNRESOLVED_CALENDAR_ID - - -async def test_calendar_id_fn_raising_records_unresolved_not_primary(store): - """A supplied resolver that raises is still an absence of a real answer - β€” it must not fall back to "primary" either.""" - - def _boom() -> str: - raise RuntimeError("no session bound") - - patcher = JournalingPatcher(_FakePatcher(), store, calendar_id_fn=_boom) - await patcher.apply_patch( - stage="Refine", current=SimpleNamespace(date=DAY), user_message="x", constraints=[] - ) - - assert await store.by_day("primary", DAY) == [] - - rows = await store.by_day(UNRESOLVED_CALENDAR_ID, DAY) - assert len(rows) == 1 - - -async def test_submit_with_no_calendar_id_records_unresolved_not_primary(store): - """No caller in agent.py passes calendar_id to submit_plan today, so the - kwargs.get(..., "primary") default silently attributed every commit row - to "primary" β€” the same defect as the patcher's calendar_id_fn.""" - sub = JournalingSubmitter(_FakeSubmitter(), store) - tx = await sub.submit_plan(SimpleNamespace(date=DAY)) - - assert await store.by_day("primary", DAY) == [] - - rows = await store.by_day(UNRESOLVED_CALENDAR_ID, DAY) - assert len(rows) == 1 - assert rows[0].kind is EntryKind.COMMIT - assert rows[0].calendar_id == UNRESOLVED_CALENDAR_ID - assert getattr(tx, "tmbx_calendar_id", None) == UNRESOLVED_CALENDAR_ID - - -async def test_submit_with_explicit_calendar_id_is_unchanged(store): - """When a caller does supply a calendar id, behaviour is unchanged.""" - sub = JournalingSubmitter(_FakeSubmitter(), store) - await sub.submit_plan(SimpleNamespace(date=DAY), calendar_id="cal-explicit") - - assert await store.by_day(UNRESOLVED_CALENDAR_ID, DAY) == [] - - rows = await store.by_day("cal-explicit", DAY) - assert len(rows) == 1 - - -async def test_undo_of_unresolved_calendar_commit_stays_unresolved(store): - """undo_transaction reads tmbx_calendar_id off the stamped transaction. - If the commit itself was unresolved, the undo row must stay unresolved - too rather than falling back to "primary".""" - sub = JournalingSubmitter(_FakeSubmitter(), store) - tx = await sub.submit_plan(SimpleNamespace(date=DAY)) - await sub.undo_transaction(tx) - - assert await store.by_day("primary", DAY) == [] - - rows = await store.by_day(UNRESOLVED_CALENDAR_ID, DAY) - assert len(rows) == 2 - assert rows[1].kind is EntryKind.UNDO - assert rows[1].calendar_id == UNRESOLVED_CALENDAR_ID - - -# ── Important 4: __getattr__ passthrough ─────────────────────────────────── - - -async def test_patcher_getattr_passes_through_to_inner(store): - inner = _FakePatcher() - inner.some_marker = "sentinel" - patcher = JournalingPatcher(inner, store, calendar_id_fn=lambda: "primary") - assert patcher.some_marker == "sentinel" - - -async def test_submitter_getattr_passes_through_to_inner(store): - inner = _FakeSubmitter() - inner.some_marker = "sentinel" - sub = JournalingSubmitter(inner, store) - assert sub.some_marker == "sentinel" - - -async def test_submitter_last_transaction_passes_through(store): - inner = _FakeSubmitter() - sentinel_tx = SimpleNamespace(status="committed") - inner.last_transaction = sentinel_tx - sub = JournalingSubmitter(inner, store) - assert sub.last_transaction is sentinel_tx - - -# ── Legacy path: apply_patch_legacy must be journaled explicitly ────────── -# TimeboxPatcher.apply_patch_legacy calls self.apply_patch(...) on the -# *inner* (unwrapped) patcher, so relying on __getattr__ passthrough alone -# would leave this path unjournaled even though it resolves and works. - - -async def test_legacy_patch_writes_attempt_row(store): - patcher = JournalingPatcher(_FakePatcher(), store, calendar_id_fn=lambda: "primary") - result = await patcher.apply_patch_legacy( - stage="Refine", - current=SimpleNamespace(date=DAY), - user_message="move lunch", - constraints=[SimpleNamespace(hints={"uid": "c1", "extraction_reason": "graphflow_turn"})], - ) - assert result is not None - - rows = await store.by_day("primary", DAY) - assert len(rows) == 1 - assert rows[0].kind is EntryKind.ATTEMPT - assert rows[0].outcome is PatchOutcome.APPLIED - assert rows[0].instruction == "move lunch" - assert rows[0].ops_json == "{}" - assert rows[0].get_constraints()[0].uid == "c1" - - -async def test_legacy_patch_failure_writes_failure_row_and_reraises(store): - patcher = JournalingPatcher( - _FakePatcher(raises=ValueError("bad legacy patch")), - store, - calendar_id_fn=lambda: "primary", - ) - with pytest.raises(ValueError): - await patcher.apply_patch_legacy( - stage="Refine", current=SimpleNamespace(date=DAY), user_message="x", constraints=[] - ) - - rows = await store.by_day("primary", DAY) - assert rows[0].kind is EntryKind.ATTEMPT - assert rows[0].outcome is PatchOutcome.APPLY_FAILED - assert "bad legacy patch" in (rows[0].error or "") - - -# ── Minor: submit_plan / undo_transaction journal inner failures too ────── - - -async def test_submit_failure_writes_failure_row_and_reraises(store): - class _RaisingSubmitter: - async def submit_plan(self, desired, **kwargs): - raise RuntimeError("calendar api down") - - sub = JournalingSubmitter(_RaisingSubmitter(), store) - with pytest.raises(RuntimeError): - await sub.submit_plan(SimpleNamespace(date=DAY), calendar_id="primary") - - rows = await store.by_day("primary", DAY) - assert rows[0].kind is EntryKind.COMMIT - assert rows[0].outcome is PatchOutcome.APPLY_FAILED - assert "calendar api down" in (rows[0].error or "") - - -async def test_undo_failure_writes_failure_row_and_reraises(store): - class _RaisingUndoSubmitter: - async def submit_plan(self, desired, **kwargs): - return SimpleNamespace(status="committed", ops=[], results=[]) - - async def undo_transaction(self, tx): - raise RuntimeError("undo api down") - - sub = JournalingSubmitter(_RaisingUndoSubmitter(), store) - tx = await sub.submit_plan(SimpleNamespace(date=DAY), calendar_id="primary") - with pytest.raises(RuntimeError): - await sub.undo_transaction(tx) - - rows = await store.by_day("primary", DAY) - assert rows[1].kind is EntryKind.UNDO - assert rows[1].outcome is PatchOutcome.APPLY_FAILED - assert "undo api down" in (rows[1].error or "") - assert rows[1].undoes_tx == rows[0].tx_id diff --git a/tests/unit/tmbx/test_patch_order_is_preserved.py b/tests/unit/tmbx/test_patch_order_is_preserved.py index 5ab144ff..7ebabc68 100644 --- a/tests/unit/tmbx/test_patch_order_is_preserved.py +++ b/tests/unit/tmbx/test_patch_order_is_preserved.py @@ -62,7 +62,6 @@ from tmbx.calendar.port import CalendarEvent from tmbx.core.models import Plan from tmbx.core.ops import Patch, apply_ops -from tmbx.journal.instrument import _ops_json from tmbx.journal.store import JournalStore, init_journal from tmbx.server import _candidate_digest as _tmbx_candidate_digest from tmbx.server import build_server @@ -377,14 +376,13 @@ def test_a_resubmission_in_a_different_order_is_a_different_submission( @SHAPES def test_the_journalled_ops_replay_into_the_day_that_was_applied(ops): - """`ops_json` is `patch.model_dump_json()`, and the journal is a - training record β€” a row whose ops disagree with the day they produced - teaches the wrong thing forever, and nothing would ever contradict it. - Both serialisers on that path are exercised: `PlanService._journal` - uses the model's own dump, `JournalingPatcher` goes through `_ops_json`. + """The journal is a training record β€” a row whose ops disagree with the + day they produced teaches the wrong thing forever, and nothing would + ever contradict it. `PlanService._journal` serialises through the + model's own `model_dump_json()`, which is what this replays. """ patch = Patch.model_validate({"ops": ops()}) - for serialised in (patch.model_dump_json(), _ops_json(patch)): + for serialised in (patch.model_dump_json(),): replayed = Patch.model_validate_json(serialised) assert _handles(replayed.ops) == LISTED diff --git a/tickets/harness_bounded_brief_says_what_it_withheld.md b/tickets/harness_bounded_brief_says_what_it_withheld.md new file mode 100644 index 00000000..24caf30d --- /dev/null +++ b/tickets/harness_bounded_brief_says_what_it_withheld.md @@ -0,0 +1,19 @@ +# Ticket: when the planning brief is bounded, it must say what it withheld + +## Tracking +- Status: Open, not started. Filed from the legacy-agent retirement (2026-09-09). +- Not blocking: the harness caps nothing today. + +## Why +Legacy commit 3dea6ae added "N lower-priority constraints did not fit this +pass" after constraints were dropped silently -- the third time that rule was +rediscovered (e0c1f30, #177). The harness puts every applicable row into the +brief (`harness_bridge.py:435`), so nothing is withheld and nothing is lost by +retiring the agent. But `harness_bridge.py:428` already notes 40 rows is +~4.5k tokens per round trip. The day the brief is bounded, nothing on this +path will make the truncation speak. + +## Done when +A bounded brief carries a count of what it left out, and the stage card +renders it in the same place `_off_today_line` renders the day-type +suspensions (`timeboxing_cards.py:342`). diff --git a/tickets/recover_two_lines_the_retirement_left_untested.md b/tickets/recover_two_lines_the_retirement_left_untested.md new file mode 100644 index 00000000..b83c99a1 --- /dev/null +++ b/tickets/recover_two_lines_the_retirement_left_untested.md @@ -0,0 +1,80 @@ +# πŸ“‹ Ticket: Recover the two lines the legacy retirement left untested + +## Tracking + +- Status: Open β€” not blocking +- Branch: `chore/retire-legacy-timeboxing-agent` + +## Why + +The legacy-agent retirement's coverage diff (`scripts/dev/tests/covdiff.py` +against PR #396, Task 7, corrected in the 2026-09-09 fix wave β€” see +`.superpowers/sdd/2026-09-09-retire-legacy-timeboxing-agent/task-7-report.md` +Β§3) found several surviving files that lost covered lines a now-deleted +test used to reach. Most of those turned out to be dead code the +retirement exposed rather than caused: `agents/schedular/models/calendar.py` +and `agents/schedular/models/core.py:143`'s only consumer, +`CalendarEventWorkerAgent` (`agents/schedular/agent.py:~901`), is never +registered in `runtime.py`; and most of +`agents/timeboxing/durable_constraint_store.py`'s lost lines +(325-329, 334-336, 357) are `get_store_info` (zero callers) and +`callable(...)` fallbacks the live client never takes. + +Two lines are not that shape β€” they are ordinary-day code paths that live +callers do reach, now with no test exercising them: + +1. `src/fateforger/contracts.py:51` β€” `EventDateTime._parse_date`'s string + branch. An all-day Google Calendar event arrives as `{"date": "2026-09-09"}` + rather than `{"dateTime": ...}`; `src/fateforger/haunt/reconcile.py:1036` + imports `EventDateTime` from `contracts` and depends on this parse to + resolve the event's date. This is not a rare edge case β€” every all-day + event on the calendar takes this path. +2. `src/fateforger/agents/timeboxing/durable_constraint_store.py:506` β€” + inside `ClientBackedDurableConstraintStore.find_equivalent_constraints`, + the `if not rows and search_names:` re-query: when the first + `query_constraints` call (scoped by a `text_query` built from + `search_names`) returns no rows, it re-queries once more with + `require_active: False` and no `text_query` β€” the ordinary "the + narrow text search matched nothing, widen it" path, not a rare failure + mode. + +## Scope + +Two tests, one per line: + +1. **`contracts.py:51`.** Construct an `EventDateTime` (or the model that + embeds it) with `date="2026-09-09"` (a string, as Google's calendar API + sends an all-day event) and assert the parsed `.date` field is + `date(2026, 9, 9)` β€” not a passthrough of the raw string. File: + `tests/unit/core/test_contracts.py` (create if it does not exist) or + alongside `contracts.py`'s existing tests if a file already covers other + `EventDateTime` fields. +2. **`durable_constraint_store.py:506`.** Build a `ClientBackedDurableConstraintStore` + over a fake/double client whose `query_constraints` returns `[]` on its + first call and a non-empty list on its second, then call + `find_equivalent_constraints(records=[...])` with at least one record + that has a `name` (so `search_names` is non-empty). Assert the result + reflects the second call's rows, and that the client's second call's + `filters` omitted `text_query` (present on the first call, dropped on + the retry). File: `tests/unit/timeboxing/test_durable_constraint_store.py` + (create if none exists) or the nearest existing test module for this + class. + +## Out of scope + +- `debug/diag.py:46-48` (the `except Exception` arm of `with_timeout`) β€” + also a real regression per the same coverage diff, but not part of this + ticket; it is an accepted known gap noted in the PR body. +- Deleting the dead-code lines this same diff exposed + (`agents/schedular/models/calendar.py`, `core.py:143`, + `durable_constraint_store.py:325-329, 334-336, 357`, the two + `llm/factory.py` branches, `tmbx/journal/store.py`'s + `journal_sessionmaker`) β€” a source change with its own follow-up, not a + test-recovery change. + +## Done when + +- The two tests above exist, pass, and each fails if its target line is + reverted to the pre-fix (i.e. untested) behavior; +- `PYTHONPATH=src /Users/hugoevers/VScode-projects/admonish-1/.venv/bin/python -m pytest tests/unit/core/test_contracts.py tests/unit/timeboxing/test_durable_constraint_store.py -q` + (or wherever the tests actually land) passes. diff --git a/tickets/skeleton_pre_generation.md b/tickets/skeleton_pre_generation.md index dc22e5d6..19317386 100644 --- a/tickets/skeleton_pre_generation.md +++ b/tickets/skeleton_pre_generation.md @@ -2,7 +2,14 @@ ## Tracking -- Status: Implemented, Tested (2026-02-13) +- Status: Implemented, Tested (2026-02-13). **The confirm/undo wiring this + ticket describes (AC2/AC3, `ff_timebox_confirm_submit`, + `ff_timebox_undo_submit`) was retired with the legacy agent on + `chore/retire-legacy-timeboxing-agent` (2026-09-09)** β€” a press on one of + those buttons now just rewrites the card to say the flow is retired + (`retired_cards.py`). Undo lives on the harness now: the Slack action is + `ff_harness_undo`, handled by `handlers.act_harness_undo`, which reverses + the reported tmbx transaction directly. - System of record issue: https://github.com/hugocool/FateForger/issues/7 - Issue branch: `issue/7-skeleton-pre-generation` - PR: https://github.com/hugocool/FateForger/pull/8 @@ -131,8 +138,11 @@ Matches existing codebase: ## Validation Executed -- `poetry run pytest tests/unit/test_timeboxing_skeleton_pre_generation.py tests/unit/test_timeboxing_submit_flow.py tests/unit/test_timeboxing_review_submit_prompt.py tests/integration/test_slack_timebox_buttons.py -q` -- `poetry run pytest tests/unit/test_timeboxing_graphflow_state_machine.py tests/unit/test_phase4_rewiring.py tests/unit/test_slack_timeboxing_routing.py tests/unit/test_timeboxing_commit_skips_initial_extraction.py -q` +- ~~`poetry run pytest tests/unit/test_timeboxing_skeleton_pre_generation.py tests/unit/test_timeboxing_submit_flow.py tests/unit/test_timeboxing_review_submit_prompt.py tests/integration/test_slack_timebox_buttons.py -q`~~ +- ~~`poetry run pytest tests/unit/test_timeboxing_graphflow_state_machine.py tests/unit/test_phase4_rewiring.py tests/unit/test_slack_timeboxing_routing.py tests/unit/test_timeboxing_commit_skips_initial_extraction.py -q`~~ +- Those six files went with the 2026-09-09 legacy-agent retirement; the + surviving equivalents are `tests/unit/slack/test_retired_cards.py` and + `tests/unit/timeboxing/test_slack_timeboxing_routing.py`. ## Notes / Remaining Human Verification diff --git a/tickets/test_suite_docs_after_legacy_retirement.md b/tickets/test_suite_docs_after_legacy_retirement.md new file mode 100644 index 00000000..bc31c68f --- /dev/null +++ b/tickets/test_suite_docs_after_legacy_retirement.md @@ -0,0 +1,128 @@ +# πŸ“‹ Ticket: Bring the docs back in line with the legacy-agent retirement + +## Tracking + +- Status: Done in c38bf3f for items 1-5; item 6 (docs/architecture) done in this fix wave +- Branch: `chore/retire-legacy-timeboxing-agent` + +## Why + +`TimeboxingFlowAgent` and the 34 modules only it reached are gone (55 source +files / 18,503 lines in the first cut, plus `adapters/calendar/models.py`, +`constraint_review.py`, `tool_result_models.py`, and the `ConstraintStore` +session-store class in the two follow-up cuts). `tests/README.md`'s "What's +out" section, two root docs, and four package READMEs still describe pieces +of that code as live, or describe backends it owned as still belonging to it. + +## Scope + +**1. `tests/README.md` β€” "What's out of `tests/unit/`" (already partly done). Done in c38bf3f.** + +A second paragraph naming the 2026-09-09 retirement, the 34 deleted modules, +the ~60 test files that went with them, and the 2 that were rewired instead +was added directly by this PR (the same session that filed this ticket) β€” +confirm it reads correctly and extend it if the retirement PR's final file +count differs from what's there. The doubles list is unchanged β€” this +retirement touched no shared test double. + +**2. `CALENDAR_QUERY_LOCATIONS.md` and `MIGRATION_ARCHIVE_TO_CALENDAR_HAUNTER.md` +(the two root docs `tests/README.md` already marks superseded). Done in c38bf3f.** + +Both already carry a "Superseded (2026-09)" note pointing at +`fateforger/haunt/` in place of `CalendarHaunter`. Add one line to each +saying `McpCalendarClient` (`fateforger.agents.timeboxing.mcp_clients`) is +also gone now β€” it left with the legacy agent β€” and that `src/tmbx/calendar/` +(`port.py`, `gcal.py`, `fake.py`) is the calendar port on the surviving path. +Do not rewrite anything else in either file; they stay a record of what was +true when written. + +**3. `src/fateforger/agents/timeboxing/README.md` and `AGENTS.md`. Done in c38bf3f.** + +Both describe the Graphiti-backed durable memory and the `constraint_mcp` +backend as belonging to the timeboxing agent β€” read the "Graphiti durable +memory cutover" status row, the `graphiti_constraint_memory.py` / +`constraint_record_memory.py` file-index entries, and `AGENTS.md`'s +"Durable constraint retrieval is centralized in `constraint_retriever.py`" +invariant. `constraint_retriever.py`, `constraint_search_tool.py`, and +`notion_constraint_extractor.py` are deleted; the surviving durable-memory +backends (`graphiti_constraint_memory.py`, `constraint_record_memory.py`, +`kg_constraint_client` β†’ `durable_constraint_store` β†’ +`DeepSeekTimeboxPlanner`) are read through `settings.timeboxing_memory_backend` +by `runtime.py`'s graphiti startup checks and by tasks' defaults memory now, +not by the (deleted) coordinator. Rewrite the status table row, the file +index entries, and the `AGENTS.md` invariant to say the backends are the +tasks-defaults-memory path's, not the timeboxing agent's own β€” and remove or +correct any surviving reference to `constraint_retriever.py`, +`constraint_search_tool.py`, or `notion_constraint_extractor.py` as if they +still exist. Two concrete hits to fix while in these files: `README.md`'s +`mcp_clients.py` file-index row still names `McpCalendarClient` as a live +export (it's deleted; `mcp_clients.py` now holds only `ConstraintMemoryClient`); +`AGENTS.md`'s "Framework First" section cites `nodes/nodes.py` (the whole +`nodes/` package is deleted) as an example of `GraphFlow`/`DiGraphBuilder` +usage β€” replace the citation or drop it. + +**4. `src/fateforger/core/README.md`. Done in c38bf3f.** + +Same correction as item 3, scoped to this file's own claim: it currently +reads as if `TIMEBOXING_MEMORY_BACKEND=graphiti` and the Graphiti startup +checks exist for the timeboxing agent. Say they serve `runtime.py`'s startup +checks and tasks' defaults memory now. + +**5. `src/fateforger/slack_bot/README.md`. Done in c38bf3f.** + +Commit `6f93212` (this retirement) deleted the `constraint_review.py` file- +index row and the `timeboxing_constraint_review` / +`ff_timeboxing_constraint_review_all` action-id rows without replacement +text β€” the modal-based constraint review surface those rows described is +gone (its only writers were the legacy agent and a review modal only it +posted). Add one line where those rows were, saying the surface is gone and +naming why (no writer left after the retirement), rather than leaving the +removal silent. + +**6. `docs/architecture/agents.md`, `docs/architecture/timeboxing_refactor.md`, +`docs/indices/agents_timeboxing.md`. Done in the 2026-09-09 fix wave.** + +`agents.md` opened with `TimeboxingFlowAgent` as the primary planner (code +pointer `agents/timeboxing/agent.py`, deleted) and named `ConstraintRetriever` +(`constraint_retriever.py`, deleted) as a live component; +`timeboxing_refactor.md`'s code pointers name `stage_gating.py` and other +deleted coordinator files. Rewrote `agents.md` to the three live components β€” +the adaptive kernel (`adaptive_timeboxing.py`, driven from +`slack_bot/timeboxing_host.py`), the harness planner +(`slack_bot/deepseek_timebox_planner.py`, reading constraints via +`kg_constraint_client.py` β†’ `durable_constraint_store.py`), and the tmbx +server (`src/tmbx/server.py`) for calendar writes β€” with the retired sections +marked with the retirement date and commit. Added a superseded line (not a +rewrite) to `timeboxing_refactor.md` pointing at `agents.md` and +`agents_timeboxing.md`; `agents_timeboxing.md` already carried a full +retirement note and needed no change. + +## Out of scope + +Deleting any further dead code the retirement exposed but did not itself +require deleting (`llm/factory.py`'s `calendar_submitter`/`timebox_patcher` +branches, `tmbx/journal/store.py`'s `journal_sessionmaker`) β€” that is a +source change with its own follow-up, not a docs change. + +`docs/superpowers/research/` and `docs/superpowers/plans/` β€” dated records +of what was true when written; a path that has since moved is not an error +in them. + +## Done when + +- `tests/README.md`'s "What's out" section names the retirement and no + longer only lists the earlier six-file prune; +- the two root calendar docs name `McpCalendarClient`'s removal and + `tmbx/calendar/` as its replacement; +- `src/fateforger/agents/timeboxing/README.md`, its `AGENTS.md`, and + `src/fateforger/core/README.md` describe the graphiti/constraint-memory + backends as serving tasks' defaults memory and `runtime.py`'s startup + checks, not the (deleted) timeboxing coordinator; +- `src/fateforger/slack_bot/README.md` explains, rather than silently + omits, the two rows Task 6 removed; +- `docs/architecture/agents.md` names the three live components, not the + deleted coordinator, as the primary planner; `docs/architecture/timeboxing_refactor.md` + carries a superseded line rather than dead code pointers; +- `grep -rn "TimeboxingFlowAgent\|McpCalendarClient\|constraint_review\|timeboxing_submit\|nodes/nodes" --include="*.md" src tests docs/architecture docs/indices README_CALENDAR_MCP.md GOOGLE_CALENDAR_MCP_GUIDE.md` + returns only lines that say the thing is gone; +- the docs commit is part of this PR. diff --git a/tickets/timeboxing_constraint_observability_gap.md b/tickets/timeboxing_constraint_observability_gap.md index d2ff1639..e93f6209 100644 --- a/tickets/timeboxing_constraint_observability_gap.md +++ b/tickets/timeboxing_constraint_observability_gap.md @@ -26,7 +26,8 @@ This blocks root-cause analysis when patching fails or drifts from user preferen 3. Timebox audit script can answer β€œselected/extracted/persisted/applied” for one session without manual grep. ## Notes -- This repo now includes the missing runtime events in `TimeboxingFlowAgent`. +- This repo now includes the missing runtime events in `TimeboxingFlowAgent` + (retired 2026-09-09; the events now live on the adaptive kernel path). - Remaining work is operational: - restore GitHub auth - open upstream issue