Skip to content
Merged
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
127 changes: 54 additions & 73 deletions hindsight-api-slim/tests/test_codex_tool_choice.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,22 @@
import asyncio
import sys
import types
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
from pathlib import Path

"""
Regression tests for Codex provider tool_choice normalization.

ROOT = Path(__file__).resolve().parents[1]
PACKAGE_ROOT = ROOT / "hindsight_api"
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
The reflect agent forces tool selection via named tool_choice dicts on early iterations:
{"type": "function", "function": {"name": "recall"}}

The Codex Responses API expects the function name at the top level instead:
{"type": "function", "name": "recall"}

def ensure_package(name: str, path: Path) -> None:
module = sys.modules.get(name)
if module is None:
module = types.ModuleType(name)
module.__path__ = [str(path)]
sys.modules[name] = module
Without normalization, Codex rejects the request with:
400 Unknown parameter: 'tool_choice.function'
"""

from unittest.mock import AsyncMock, MagicMock, patch

ensure_package("hindsight_api", PACKAGE_ROOT)
ensure_package("hindsight_api.engine", PACKAGE_ROOT / "engine")
ensure_package("hindsight_api.engine.providers", PACKAGE_ROOT / "engine" / "providers")
fake_metrics = types.ModuleType("hindsight_api.metrics")
fake_metrics.get_metrics_collector = lambda: types.SimpleNamespace(record_llm_call=lambda **kwargs: None)
sys.modules["hindsight_api.metrics"] = fake_metrics
import pytest

from hindsight_api.engine.providers.codex_llm import CodexLLM


TOOLS = [
{
"type": "function",
Expand All @@ -56,52 +43,46 @@ def build_llm() -> CodexLLM:
)


class CodexToolChoiceTests(unittest.TestCase):
def test_codex_normalizes_legacy_named_tool_choice_shape(self) -> None:
async def scenario() -> dict:
llm = build_llm()
response = MagicMock()
response.status_code = 200
response.raise_for_status.return_value = None
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = response
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
mock_parse.return_value = (None, [])
await llm.call_with_tools(
messages=[{"role": "user", "content": "recall the memory"}],
tools=TOOLS,
tool_choice={"type": "function", "function": {"name": "recall"}},
max_retries=0,
)
return mock_post.call_args.kwargs["json"]

sent_payload = asyncio.run(scenario())
self.assertEqual(sent_payload["tool_choice"], {"type": "function", "name": "recall"})

def test_codex_forced_tool_choice_still_yields_tool_calls(self) -> None:
async def scenario() -> tuple[object, dict]:
llm = build_llm()
response = MagicMock()
response.status_code = 200
response.raise_for_status.return_value = None
tool_call = {"id": "call-1", "name": "recall", "arguments": {"query": "memory"}}
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = response
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
mock_parse.return_value = (None, [tool_call])
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "recall the memory"}],
tools=TOOLS,
tool_choice={"type": "function", "function": {"name": "recall"}},
max_retries=0,
)
return result, mock_post.call_args.kwargs["json"]

result, sent_payload = asyncio.run(scenario())
self.assertEqual(len(result.tool_calls), 1)
self.assertEqual(result.tool_calls[0].name, "recall")
self.assertEqual(sent_payload["tool_choice"], {"type": "function", "name": "recall"})


if __name__ == "__main__":
unittest.main()
@pytest.mark.asyncio
async def test_codex_normalizes_legacy_named_tool_choice_shape():
llm = build_llm()
response = MagicMock()
response.status_code = 200
response.raise_for_status.return_value = None
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = response
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
mock_parse.return_value = (None, [])
await llm.call_with_tools(
messages=[{"role": "user", "content": "recall the memory"}],
tools=TOOLS,
tool_choice={"type": "function", "function": {"name": "recall"}},
max_retries=0,
)
sent_payload = mock_post.call_args.kwargs["json"]

assert sent_payload["tool_choice"] == {"type": "function", "name": "recall"}


@pytest.mark.asyncio
async def test_codex_forced_tool_choice_still_yields_tool_calls():
llm = build_llm()
response = MagicMock()
response.status_code = 200
response.raise_for_status.return_value = None
tool_call = {"id": "call-1", "name": "recall", "arguments": {"query": "memory"}}
with patch.object(llm._client, "post", new_callable=AsyncMock) as mock_post:
mock_post.return_value = response
with patch.object(llm, "_parse_sse_tool_stream", new_callable=AsyncMock) as mock_parse:
mock_parse.return_value = (None, [tool_call])
result = await llm.call_with_tools(
messages=[{"role": "user", "content": "recall the memory"}],
tools=TOOLS,
tool_choice={"type": "function", "function": {"name": "recall"}},
max_retries=0,
)
sent_payload = mock_post.call_args.kwargs["json"]

assert len(result.tool_calls) == 1
assert result.tool_calls[0].name == "recall"
assert sent_payload["tool_choice"] == {"type": "function", "name": "recall"}
10 changes: 9 additions & 1 deletion hindsight-api-slim/tests/test_hierarchical_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,16 @@ async def test_hierarchical_fields_categorization():
assert "entities_allow_free_form" in configurable
assert "entity_labels" in configurable

# Verify other configurable fields
assert "retain_default_strategy" in configurable
assert "retain_strategies" in configurable
assert "max_observations_per_scope" in configurable
assert "reflect_source_facts_max_tokens" in configurable
assert "llm_gemini_safety_settings" in configurable
assert "mcp_enabled_tools" in configurable

# Verify count is correct
assert len(configurable) == 20
assert len(configurable) == 21

# Verify credential fields (NEVER exposed)
assert "llm_api_key" in credentials
Expand Down
6 changes: 6 additions & 0 deletions hindsight-docs/sidebars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,12 @@ const sidebars: SidebarsConfig = {
label: 'AG2',
customProps: { icon: '/img/icons/ag2.svg' },
},
{
type: 'doc',
id: 'sdks/integrations/llamaindex',
label: 'LlamaIndex',
customProps: { icon: '/img/icons/llamaindex.png' },
},
{
type: 'doc',
id: 'sdks/integrations/skills',
Expand Down
189 changes: 189 additions & 0 deletions hindsight-docs/versioned_docs/version-0.4/sdks/integrations/codex.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
---
sidebar_position: 6
---

# OpenAI Codex CLI

Persistent memory for [OpenAI Codex CLI](https://github.com/openai/codex) using [Hindsight](https://vectorize.io/hindsight). Three Python hook scripts automatically recall relevant context before each prompt and retain conversations after each turn — no changes to your Codex workflow required.

## Quick Start

```bash
# 1. Clone the Hindsight repo and install the plugin
git clone https://github.com/vectorize-io/hindsight.git
cd hindsight/hindsight-integrations/codex
./install.sh

# 2. Configure your Hindsight connection
cat > ~/.hindsight/codex.json << 'EOF'
{
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "hsk_your_token_here",
"bankId": "codex"
}
EOF

# 3. Start Codex — memory is live
codex
```

For a local Hindsight instance, set `hindsightApiUrl` to `http://localhost:9077` and omit `hindsightApiToken`.

## Features

- **Auto-recall** — on every user prompt, queries Hindsight for relevant memories and injects them as `additionalContext` (invisible to the transcript, visible to Codex)
- **Auto-retain** — after each Codex response, stores the conversation transcript to Hindsight for future recall
- **Dynamic bank IDs** — supports per-project memory isolation based on the working directory
- **Session-level upsert** — uses the session ID as the document ID so re-running the same session updates rather than duplicates stored content
- **Zero dependencies** — pure Python stdlib, no pip install required

## Architecture

The plugin uses three Codex hook events:

| Hook | Event | Purpose |
|------|-------|---------|
| `session_start.py` | `SessionStart` | Warm up — verify Hindsight is reachable |
| `recall.py` | `UserPromptSubmit` | **Auto-recall** — query memories, inject as `additionalContext` |
| `retain.py` | `Stop` | **Auto-retain** — extract transcript, POST to Hindsight (async) |

On `UserPromptSubmit`, the hook reads the prompt, queries Hindsight for the most relevant memories, and outputs a `hookSpecificOutput.additionalContext` block. Codex prepends this to the conversation before sending it to the model:

```
<hindsight_memories>
Relevant memories from past conversations...
Current time - 2026-03-27 09:14

- Project uses FastAPI with asyncpg — not SQLAlchemy [world] (2026-03-26)
- Preferred testing framework: pytest with pytest-asyncio [experience] (2026-03-26)
</hindsight_memories>
```

On `Stop`, the hook reads the session transcript, strips previously injected memory tags (to prevent feedback loops), and POSTs the conversation to Hindsight asynchronously.

## Connection Modes

### 1. External API (recommended)

Connect to a running Hindsight server (cloud or self-hosted):

```json
{
"hindsightApiUrl": "https://api.hindsight.vectorize.io",
"hindsightApiToken": "hsk_your_token"
}
```

### 2. Local Daemon

Run `hindsight-embed` locally. The `session_start.py` hook will detect it on `apiPort` (default `9077`). The daemon is not auto-started by the Codex plugin — start it separately:

```bash
uvx hindsight-embed
```

Then leave `hindsightApiUrl` empty in your config and the plugin will connect to `http://localhost:9077`.

## Configuration

Settings are loaded from `~/.hindsight/codex.json`. Every setting can also be overridden via environment variable.

**Loading order** (later entries win):

1. Built-in defaults
2. Plugin `settings.json` (at `~/.hindsight/codex/settings.json`)
3. User config (`~/.hindsight/codex.json`)
4. Environment variables

---

### Connection

| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `hindsightApiUrl` | `HINDSIGHT_API_URL` | `""` | URL of the Hindsight API server. Required. |
| `hindsightApiToken` | `HINDSIGHT_API_TOKEN` | `null` | API token for authentication. Required for Hindsight Cloud. |
| `apiPort` | `HINDSIGHT_API_PORT` | `9077` | Port for the local `hindsight-embed` daemon. |

---

### Memory Bank

| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `bankId` | `HINDSIGHT_BANK_ID` | `"codex"` | The bank to read from and write to. All sessions share this bank unless `dynamicBankId` is enabled. |
| `bankMission` | `HINDSIGHT_BANK_MISSION` | coding assistant prompt | Describes the agent's purpose. Sent when creating or updating the bank. |
| `retainMission` | — | extraction prompt | Instructions for Hindsight's fact extraction — what to extract from coding conversations. |
| `dynamicBankId` | `HINDSIGHT_DYNAMIC_BANK_ID` | `false` | When `true`, derives a unique bank ID from `dynamicBankGranularity` fields — useful for per-project isolation. |
| `dynamicBankGranularity` | — | `["agent", "project"]` | Which fields to combine for dynamic bank IDs. `"project"` = working directory, `"agent"` = agent name. |
| `bankIdPrefix` | — | `""` | Prefix prepended to all bank IDs. |
| `agentName` | `HINDSIGHT_AGENT_NAME` | `"codex"` | Agent name used in dynamic bank ID derivation. |

---

### Auto-Recall

| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `autoRecall` | `HINDSIGHT_AUTO_RECALL` | `true` | Master switch for auto-recall. |
| `recallBudget` | `HINDSIGHT_RECALL_BUDGET` | `"mid"` | Search depth: `"low"` (fast), `"mid"` (balanced), `"high"` (thorough). |
| `recallMaxTokens` | `HINDSIGHT_RECALL_MAX_TOKENS` | `1024` | Max tokens in the recalled memory block. |
| `recallTypes` | — | `["world", "experience"]` | Memory types to retrieve. |
| `recallContextTurns` | `HINDSIGHT_RECALL_CONTEXT_TURNS` | `1` | Prior turns to include when building the recall query. `1` = latest prompt only. |
| `recallMaxQueryChars` | `HINDSIGHT_RECALL_MAX_QUERY_CHARS` | `800` | Max characters in the query sent to Hindsight. |
| `recallRoles` | — | `["user", "assistant"]` | Which roles to include when building a multi-turn query. |
| `recallPromptPreamble` | — | built-in | Text placed above the recalled memories in the injected context block. |

---

### Auto-Retain

| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `autoRetain` | `HINDSIGHT_AUTO_RETAIN` | `true` | Master switch for auto-retain. |
| `retainMode` | `HINDSIGHT_RETAIN_MODE` | `"full-session"` | `"full-session"` sends the full transcript per session (upserted by session ID). `"chunked"` sends sliding windows every N turns. |
| `retainEveryNTurns` | — | `10` | Retain fires every N turns. `1` = every turn. Higher values reduce API calls. |
| `retainOverlapTurns` | — | `2` | Extra turns included from the previous chunk (chunked mode only). |
| `retainRoles` | — | `["user", "assistant"]` | Which roles to include in the retained transcript. |
| `retainTags` | — | `["{session_id}"]` | Tags attached to the stored document. `{session_id}` is replaced at runtime. |
| `retainMetadata` | — | `{}` | Arbitrary key-value metadata attached to the stored document. |
| `retainContext` | — | `"codex"` | Label identifying the source integration. Useful when multiple integrations write to the same bank. |

---

### Debug

| Setting | Env Var | Default | Description |
|---------|---------|---------|-------------|
| `debug` | `HINDSIGHT_DEBUG` | `false` | Enable verbose logging to stderr. All log lines are prefixed with `[Hindsight]`. |

## Per-Project Memory

To give each project its own isolated memory bank, enable dynamic bank IDs:

```json
{
"dynamicBankId": true,
"dynamicBankGranularity": ["agent", "project"]
}
```

With this config, running Codex in `~/projects/api` and `~/projects/frontend` stores and recalls memories separately. Bank IDs are derived from the working directory path.

## Troubleshooting

**Hooks not firing**: Check that `~/.codex/config.toml` contains `codex_hooks = true` under `[features]`. Re-run `install.sh` to write this automatically.

**No memories recalled**: Recall returns results only after something has been retained. Either complete one Codex session first, or seed your bank manually using the [cookbook example](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/codex-memory).

**Memory not being stored**: `retainEveryNTurns` defaults to `10` — retain only fires every 10 turns. While testing, add `"retainEveryNTurns": 1` to `~/.hindsight/codex.json`.

**Debug mode**: Add `"debug": true` to `~/.hindsight/codex.json` to see what Hindsight is doing on each turn:

```
[Hindsight] Recalling from bank 'codex', query length: 42
[Hindsight] Injecting 3 memories
[Hindsight] Retaining to bank 'codex', doc 'sess-abc123', 2 messages, 847 chars
```

**High latency on recall**: Use `"recallBudget": "low"` or reduce `recallMaxTokens` to speed up recall queries.
Loading
Loading