Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 16 additions & 12 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ OPENAI_MODEL=gpt-4o-mini
OPENAI_BASE_URL=

# LLM Provider (OpenAI-compatible)
# - openai: uses https://api.openai.com/v1 (default)
# - openai: uses https://api.openai.com/v1
# - openrouter: uses https://openrouter.ai/api/v1
LLM_PROVIDER=openai
LLM_PROVIDER=openrouter

# OpenRouter Configuration (only used when LLM_PROVIDER=openrouter)
OPENROUTER_API_KEY=or-your-openrouter-api-key-here
Expand Down Expand Up @@ -96,19 +96,23 @@ DEVELOPMENT=true
SCHEDULER_TIMEZONE=UTC

# Durable memory backend selection
# - TIMEBOXING_MEMORY_BACKEND: constraint_mcp|mem0
# - TASKS_DEFAULTS_MEMORY_BACKEND: constraint_mcp|mem0|disabled|inherit_timeboxing
TIMEBOXING_MEMORY_BACKEND=constraint_mcp
TASKS_DEFAULTS_MEMORY_BACKEND=constraint_mcp
# Single durable memory backend (Graphiti only)
TIMEBOXING_MEMORY_BACKEND=graphiti
TASKS_DEFAULTS_MEMORY_BACKEND=graphiti
# Optional fallback cache path when durable task defaults backend is unavailable.
TASKS_DEFAULTS_CACHE_PATH=logs/taskmarshal_defaults_cache.json

# Mem0 backend settings (required only when a selected backend is mem0)
MEM0_USER_ID=timeboxing
MEM0_IS_CLOUD=false
MEM0_API_KEY=
MEM0_LOCAL_CONFIG_JSON=
MEM0_QUERY_LIMIT=200
# Graphiti MCP backend settings (required when a selected backend is graphiti)
GRAPHITI_USER_ID=timeboxing
# Host-run app default. Docker compose overrides this inside the slack-bot container
# to http://graphiti-mcp:8000/mcp.
GRAPHITI_MCP_SERVER_URL=http://localhost:8005/mcp
GRAPHITI_MCP_GROUP_ID=timeboxing
GRAPHITI_MCP_TIMEOUT_SECONDS=15
GRAPHITI_QUERY_LIMIT=200
GRAPHITI_MCP_HOST_PORT=8005
GRAPHITI_LLM_MODEL=google/gemini-3-flash-preview
GRAPHITI_EMBEDDER_MODEL=openai/text-embedding-3-small

# AutoGen event logging controls
# summary|full|off
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,4 @@ data/*.db
# Logs
logs/
*.log
.worktrees/
19 changes: 19 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ services:
# Single source of truth: define MCP_CALENDAR_SERVER_URL_DOCKER in `.env`
# and pass it through to the app container as MCP_CALENDAR_SERVER_URL.
MCP_CALENDAR_SERVER_URL: "${MCP_CALENDAR_SERVER_URL_DOCKER:?Set MCP_CALENDAR_SERVER_URL_DOCKER in .env}"
# Graphiti MCP is reachable via docker service name on the compose network.
GRAPHITI_MCP_SERVER_URL: "http://graphiti-mcp:8000/mcp"
volumes:
- app_data:/app/data
- poetry-cache:/root/.cache/pypoetry
Expand Down Expand Up @@ -149,6 +151,23 @@ services:
restart: "no"
networks:
- admonish-network

graphiti-mcp:
container_name: graphiti-mcp
image: zepai/knowledge-graph-mcp:latest
env_file: .env
environment:
# Graphiti uses an OpenAI-compatible client; route it via OpenRouter.
OPENAI_API_KEY: "${OPENROUTER_API_KEY:?Set OPENROUTER_API_KEY in .env for graphiti-mcp}"
OPENAI_API_URL: "${OPENROUTER_BASE_URL:-https://openrouter.ai/api/v1}"
LLM__MODEL: "${GRAPHITI_LLM_MODEL:-google/gemini-3-flash-preview}"
EMBEDDER__MODEL: "${GRAPHITI_EMBEDDER_MODEL:-openai/text-embedding-3-small}"
ports:
- "${GRAPHITI_MCP_HOST_PORT:-8005}:8000"
networks:
- admonish-network
restart: unless-stopped

ticktick-mcp-auth:
<<: *common
profiles: ["ticktick", "auth"] # ← only runs when you ask for it
Expand Down
111 changes: 111 additions & 0 deletions docs/plans/2026-03-08-timeboxing-session-constraint-override-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Timeboxing Session Constraint Override Design

## Goal

Make same-day explicit session facts override conflicting profile/default memory, including conditional defaults such as "go to the gym unless another sport is already planned or explicitly stated for that day."

## Problem

The current Graphiti-backed baseline can canonicalize and filter durable constraints, but it still selects defaults too aggressively when same-day context should suppress them. In practice, a profile/default activity preference can remain active even after the user has stated a conflicting same-day activity or the calendar already anchors that day around another activity.

## Design Summary

Keep the fix inside the timeboxing memory-selection path. Reuse existing `hints.aspect_classification` metadata and teach stage-context reconciliation to evaluate conditional applicability before family-level selection.

This keeps the planner from seeing avoidable noise and avoids introducing any new regex or substring heuristics. The storage model from `#81` remains intact; this is a selection-time precedence and suppression fix.

## Existing Seams

- `src/fateforger/agents/timeboxing/nlu.py`
- The interpreter prompt already defines:
- `is_conditional`
- `conditional_on_absent`
- `conditional_on_present`
- `excludes_aspect_ids`
- `src/fateforger/agents/timeboxing/agent.py`
- `_collect_constraints()` computes:
- raw active constraints
- applicable active constraints
- selected active constraints
- `_reconcile_constraints_for_stage_context()` groups constraints into relevance families and currently picks a winner without evaluating cross-aspect suppression.
- `_collect_session_aspect_ids()` already extracts explicit same-session aspect IDs.
- `src/fateforger/agents/timeboxing/constraint_reconciliation.py`
- Canonicalizes durable rows and applies date/stage applicability.
- Not the right place for session-only precedence logic.

## Recommended Approach

### 1. Build same-day aspect context

Before family reconciliation, derive a set of active aspect IDs that represent stronger same-day facts. This set should include:

- session-scoped constraint aspect IDs
- aspect IDs from already applicable same-day constraints
- explicit blockers surfaced through `excludes_aspect_ids`

This context is only for the current turn/session and should not mutate stored durable records.

### 2. Evaluate conditional applicability before reconciliation

Add a conditional-applicability filter that checks `hints.aspect_classification`:

- if `conditional_on_present` is non-empty, require one of those aspect IDs to be active
- if `conditional_on_absent` is non-empty, suppress the candidate when any listed aspect ID is active
- if `excludes_aspect_ids` overlaps the active aspect set, suppress the candidate unless the candidate itself is the stronger session-scoped fact

This should happen before selecting one winner from a relevance family.

### 3. Preserve family reconciliation, but only among eligible candidates

Keep `_constraint_relevance_family_key()` and `_constraint_rank_for_stage_reconciliation()`, but apply them only to constraints that remain eligible after conditional suppression.

If an entire family is suppressed, it should disappear from `session.active_constraints`.

### 4. Make suppression observable

Extend the existing `constraints_active_snapshot` debug event with deterministic fields:

- `active_suppressed_count`
- `active_suppressed_reasons`
- optional preview of suppressed names/reasons for the first few candidates

This preserves auditability without changing user-facing Slack copy in this ticket.

## Ownership Boundaries

- `agent.py`
- owns session-time precedence, suppression, and active-constraint selection
- `constraint_reconciliation.py`
- continues to own durable-row canonicalization and basic day/stage applicability
- `nlu.py`
- already defines the metadata contract; only prompt wording changes are needed if tests show coverage gaps

## Non-goals

- deduplicating dual extraction per Refine turn (`#104`)
- Graphiti deployment/runtime DB audit (`#90`)
- broader cross-agent memory redesign (`#64`)
- changing durable storage schema or adding migrations

## Testing Strategy

Add targeted regressions around selection semantics:

1. Session override
- A session-scoped explicit activity beats a conflicting profile/default activity.

2. Conditional suppression
- A profile/default `gym_training` preference is suppressed when another same-day sport is explicit.

3. Non-conflict preservation
- The same gym preference remains eligible when no blocker exists.

4. Observability
- The session debug snapshot exposes suppression counts and reasons deterministically.

## Risks

- Over-suppressing constraints if family/category rules are too broad.
- Hidden coupling between current family keys and the new active-aspect context.

The mitigation is to keep the first pass narrow: drive suppression only from explicit `aspect_classification` metadata already present on candidates, and prove behavior with focused unit tests before broadening the model.
199 changes: 199 additions & 0 deletions docs/plans/2026-03-08-timeboxing-session-constraint-override.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
# Timeboxing Session Constraint Override Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Make same-day explicit session constraints suppress conflicting default/profile memory in the timeboxing agent.

**Architecture:** Keep the fix in the timeboxing constraint-selection path. Reuse existing `aspect_classification` metadata to compute same-day active aspect context, suppress conditionally inapplicable defaults, then run the existing family-reconciliation ranking on the remaining candidates.

**Tech Stack:** Python 3.11, pytest, SQLModel, Pydantic, AutoGen timeboxing agent

---

### Task 1: Add Failing Regressions For Session Override And Conditional Suppression

**Files:**
- Modify: `tests/unit/test_timeboxing_constraint_selection.py`
- Reference: `src/fateforger/agents/timeboxing/agent.py`

**Step 1: Write the failing tests**

Add tests that cover:

```python
async def test_session_activity_overrides_conflicting_profile_default() -> None:
...

async def test_conditional_default_is_suppressed_by_same_day_activity() -> None:
...

async def test_conditional_default_remains_when_no_blocker_exists() -> None:
...
```

Each test should build `Constraint` objects with `hints["aspect_classification"]` metadata and call `_collect_constraints()` or `_reconcile_constraints_for_stage_context()` through a minimal `TimeboxingFlowAgent` fixture pattern already used in this test module.

**Step 2: Run test to verify it fails**

Run: `poetry run pytest tests/unit/test_timeboxing_constraint_selection.py -q`

Expected: FAIL on the new override/suppression assertions because current reconciliation does not honor conditional activity blockers.

**Step 3: Write minimal implementation**

Do not implement yet; move to Task 2.

**Step 4: Run test to verify it still isolates the gap**

Run: `poetry run pytest tests/unit/test_timeboxing_constraint_selection.py -q`

Expected: the new tests fail and existing tests remain meaningful.

**Step 5: Commit**

Do not commit in this repo unless explicitly requested by the user.

### Task 2: Implement Same-Day Conditional Suppression In Constraint Selection

**Files:**
- Modify: `src/fateforger/agents/timeboxing/agent.py`
- Reference: `src/fateforger/agents/timeboxing/preferences.py`
- Reference: `src/fateforger/agents/timeboxing/nlu.py`

**Step 1: Add helper functions for active aspect context**

Add focused helpers in `agent.py` for:

```python
def _constraint_excluded_aspect_ids(constraint: Constraint) -> set[str]:
...

def _constraint_conditional_present_ids(constraint: Constraint) -> set[str]:
...

def _constraint_conditional_absent_ids(constraint: Constraint) -> set[str]:
...

def _collect_active_aspect_ids(constraints: list[Constraint]) -> set[str]:
...
```

These helpers should read only structured metadata from `hints["aspect_classification"]`.

**Step 2: Apply conditional suppression before family reconciliation**

Update `_reconcile_constraints_for_stage_context()` so it:

```python
active_aspect_ids = self._collect_active_aspect_ids(constraints)
eligible, suppressed = self._suppress_conditionally_inapplicable_constraints(
session=session,
constraints=constraints,
active_aspect_ids=active_aspect_ids,
)
```

Then reconcile `eligible` by family/rank.

**Step 3: Preserve stronger session facts**

Ensure session-scoped explicit constraints are not suppressed by weaker profile/default constraints. Scope precedence should remain:

- `SESSION`
- `DATESPAN`
- `PROFILE`

When a conflict exists, the lower-precedence candidate should be suppressed or lose reconciliation.

**Step 4: Run targeted tests**

Run: `poetry run pytest tests/unit/test_timeboxing_constraint_selection.py -q`

Expected: PASS for the new regressions and the existing selection tests.

**Step 5: Commit**

Do not commit in this repo unless explicitly requested by the user.

### Task 3: Add Deterministic Debug Evidence For Suppression

**Files:**
- Modify: `src/fateforger/agents/timeboxing/agent.py`
- Test: `tests/unit/test_timeboxing_constraint_selection.py`

**Step 1: Write the failing observability assertion**

Add or extend a test asserting that the `constraints_active_snapshot` event contains suppression evidence, for example:

```python
assert snapshots[-1]["active_suppressed_count"] == 1
assert snapshots[-1]["active_suppressed_reasons"] == ["excluded_by_aspect"]
```

**Step 2: Run test to verify it fails**

Run: `poetry run pytest tests/unit/test_timeboxing_constraint_selection.py -q`

Expected: FAIL because the snapshot does not yet expose suppression fields.

**Step 3: Implement the minimal debug fields**

Add deterministic suppression fields to `_collect_constraints()` / reconciliation helpers and keep values bounded and structured.

**Step 4: Run test to verify it passes**

Run: `poetry run pytest tests/unit/test_timeboxing_constraint_selection.py -q`

Expected: PASS.

**Step 5: Commit**

Do not commit in this repo unless explicitly requested by the user.

### Task 4: Run The Broader Targeted Validation Subset

**Files:**
- Test: `tests/unit/test_timeboxing_constraint_selection.py`
- Test: `tests/unit/test_timeboxing_durable_constraints.py`
- Test: `tests/unit/test_constraint_retriever.py`
- Test: `tests/unit/test_timeboxing_memory_backend_selection.py`

**Step 1: Run the focused regression suite**

Run:

```bash
poetry run pytest \
tests/unit/test_timeboxing_constraint_selection.py \
tests/unit/test_timeboxing_durable_constraints.py \
tests/unit/test_constraint_retriever.py \
tests/unit/test_timeboxing_memory_backend_selection.py \
-q
```

Expected: PASS.

**Step 2: Inspect for unintended behavioral drift**

Review failures or changed assertions for:
- active count semantics
- durable stage prefetch behavior
- Graphiti backend selection

**Step 3: Update issue checkpoint**

Post a progress checkpoint to issue `#91` with:
- tests run
- current status
- remaining risks
- `Open Items`

**Step 4: Re-run cleanliness check**

Run: `git status --porcelain`

Expected: only intended files for `#91` are modified.

**Step 5: Commit**

Do not commit in this repo unless explicitly requested by the user.
Loading