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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
*.pyc
*.egg-info
*.db*
.DS_Store
.DS_Store
__pycache__
4 changes: 2 additions & 2 deletions .secrets.baseline
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"files": "^.secrets.baseline$",
"lines": null
},
"generated_at": "2026-01-19T15:46:06Z",
"generated_at": "2026-01-27T22:16:04Z",
"plugins_used": [
{
"name": "AWSKeyDetector"
Expand Down Expand Up @@ -192,7 +192,7 @@
"hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa",
"is_secret": false,
"is_verified": false,
"line_number": 486,
"line_number": 408,
"type": "Hex High Entropy String",
"verified_result": null
}
Expand Down
67 changes: 67 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# What is Kaizen?
Kaizen is a Python library and service which enables AI agents to improve through self-reflection.

## Key Concepts
- **Trajectory**: A recorded agent conversation
- **Entity**: Anything which is appropriately stored in a vector database, such as a guideline, policy, or some other knowledge.
- **Namespace**: Isolated storage for entities
- **Conflict Resolution**: LLM-based merging of duplicate/conflicting entities
- **Guidelines**: Instructions intended to assist an agent in completing some task

## Architecture Flow
1. Agent completes some task, and the resulting trajectory is automatically saved into a logging framework such as Langfuse or Arize Phoenix.
2. The agent can call the sync MCP function, or the user can manually sync, which causes kaizen to process the trajectory and save any generated guidelines.
3. Generated guidelines are stored as entities with conflict resolution applied.
4. Future agents can query the Kaizen MCP server to fetch guidelines for similar tasks

## Project Directory Tree (Some files omitted for brevity)
```text
.
├── demo (Files used by the Claude Code demo)
│   ├── filesystem
│   └── workdir
├── docs (Data used by README files)
├── explorations (Tangential projects for feeling out future work. Should be avoided unless otherwise prompted.)
│   └── claudecode
├── kaizen (Primary Source Root)
│   ├── backend (Entity Database Backend implementations, primarily vector databases)
│   ├── config (All configurations which are derived from environment variables or instantiated as an object)
│   ├── db (A sqlite database for when vector databases are a poor fit for the data)
│   ├── frontend (Interfaces to interact with the backend)
│   │   ├── cli (A CLI wrapper over the native Python client)
│   │   ├── client (A native Python client which thinly wraps the configured backend)
│   │   └── mcp (An MCP server implementing some high-level methods useful for AI agents)
│   ├── llm (All code that prompts an LLM)
│   ├── schema (All well-defined datatypes used throughout the project)
│   ├── sync (Upstream data sources to be processed and stored in the backend)
│   └── utils (Small reusable code snippets)
├── tests (All tests for kaizen)
├── .env.example (Environment variable template file)
└── .env (Environment variables used to configure kaizen)
```

## First Time Setup
```bash
uv sync && source .venv/bin/activate
cp .env.example .env # Configure any environment variables, defined in `./kaizen/config`
pre-commit install
```

## Testing Instructions
- Run pytest verbosely with the `-v` flag by default so that you have more context when tests fail.
- Use `uv run pytest tests/.../<test_name.py>` to run tests individually.
- We use the pytest markers `e2e` for end-to-end tests, and `unit` for unit tests, and `phoenix` to test integration with Phoenix.
- When running `uv run pytest` it will skip the tests marked with `phoenix`.
- To run specific markers: `uv run pytest -m e2e` or `uv run pytest -m unit`
- To override and run all: `uv run pytest -m "e2e or unit or phoenix"`

## Available Interfaces
- MCP Server: `get_guidelines()`, `save_trajectory()`
- CLI: Run `kaizen --help` if details are needed about its subcommands.
Available subcommands include `namespaces`, `entities`, and `sync`
- Python Client: `KaizenClient()` for programmatic access

## Coding Standards
- Use Ruff for linting and formatting (configured in pyproject.toml)
- Run pre-commit hooks before committing
- All new features need tests (unit + e2e where applicable)
146 changes: 40 additions & 106 deletions extract_trajectories.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@

import json
import argparse
from collections import defaultdict
from typing import Any
import urllib.request

Expand Down Expand Up @@ -49,6 +48,7 @@ def parse_content(content: Any) -> Any:
# Try to parse as Python literal
try:
import ast

return ast.literal_eval(content)
except (ValueError, SyntaxError):
return content
Expand All @@ -71,12 +71,7 @@ def extract_messages_from_span(span: dict) -> list[dict]:
role = attrs.get(f"gen_ai.prompt.{i}.role")
content = attrs.get(f"gen_ai.prompt.{i}.content")
if role and content is not None:
messages.append({
"index": i,
"type": "prompt",
"role": role,
"content": parse_content(content)
})
messages.append({"index": i, "type": "prompt", "role": role, "content": parse_content(content)})

# Extract completion messages
completion_indices = set()
Expand All @@ -89,12 +84,7 @@ def extract_messages_from_span(span: dict) -> list[dict]:
role = attrs.get(f"gen_ai.completion.{i}.role")
content = attrs.get(f"gen_ai.completion.{i}.content")
if role and content is not None:
messages.append({
"index": i,
"type": "completion",
"role": role,
"content": parse_content(content)
})
messages.append({"index": i, "type": "completion", "role": role, "content": parse_content(content)})

return messages

Expand Down Expand Up @@ -132,21 +122,22 @@ def convert_anthropic_to_openai(content: Any, role: str) -> dict:
thinking_parts.append(thinking)

elif block_type == "tool_use":
tool_calls.append({
"id": block.get("id", ""),
"type": "function",
"function": {
"name": block.get("name", ""),
"arguments": json.dumps(block.get("input", {}))
tool_calls.append(
{
"id": block.get("id", ""),
"type": "function",
"function": {"name": block.get("name", ""), "arguments": json.dumps(block.get("input", {}))},
}
})
)

elif block_type == "tool_result":
tool_results.append({
"tool_call_id": block.get("tool_use_id", ""),
"content": block.get("content", ""),
"is_error": block.get("is_error", False)
})
tool_results.append(
{
"tool_call_id": block.get("tool_use_id", ""),
"content": block.get("content", ""),
"is_error": block.get("is_error", False),
}
)

# Build OpenAI format message
if role == "assistant":
Expand Down Expand Up @@ -193,11 +184,7 @@ def extract_trajectory(span: dict) -> dict:
# Handle tool results (expand into individual messages)
if converted.get("role") == "tool" and "tool_results" in converted:
for result in converted["tool_results"]:
openai_messages.append({
"role": "tool",
"tool_call_id": result["tool_call_id"],
"content": result["content"]
})
openai_messages.append({"role": "tool", "tool_call_id": result["tool_call_id"], "content": result["content"]})
else:
openai_messages.append(converted)

Expand Down Expand Up @@ -225,15 +212,16 @@ def extract_trajectory(span: dict) -> dict:
"usage": {
"prompt_tokens": attrs.get("gen_ai.usage.prompt_tokens"),
"completion_tokens": attrs.get("gen_ai.usage.completion_tokens"),
"total_tokens": attrs.get("llm.usage.total_tokens")
}
"total_tokens": attrs.get("llm.usage.total_tokens"),
},
}


def filter_system_reminders(text: str) -> str:
"""Remove system reminders from text content."""
import re
return re.sub(r'<system-reminder>.*?</system-reminder>', '', text, flags=re.DOTALL).strip()

return re.sub(r"<system-reminder>.*?</system-reminder>", "", text, flags=re.DOTALL).strip()


def clean_trajectory(trajectory: dict, remove_system_reminders: bool = True) -> dict:
Expand All @@ -260,10 +248,7 @@ def clean_trajectory(trajectory: dict, remove_system_reminders: bool = True) ->


def get_trajectories(
base_url: str = "http://localhost:6006",
limit: int = 100,
include_errors: bool = False,
clean: bool = True
base_url: str = "http://localhost:6006", limit: int = 100, include_errors: bool = False, clean: bool = True
) -> list[dict]:
"""
Fetch and extract agent trajectories from Phoenix.
Expand Down Expand Up @@ -317,12 +302,7 @@ def get_trajectory_by_trace_id(trace_id: str, base_url: str = "http://localhost:
Returns:
Trajectory dict or None if not found
"""
trajectories = get_trajectories(
base_url=base_url,
limit=1000,
include_errors=True,
clean=True
)
trajectories = get_trajectories(base_url=base_url, limit=1000, include_errors=True, clean=True)
for t in trajectories:
if t["trace_id"] == trace_id:
return t
Expand Down Expand Up @@ -352,23 +332,20 @@ def format_trajectory_as_text(trajectory: dict, include_thinking: bool = True) -
if msg.get("tool_calls"):
for tc in msg["tool_calls"]:
func = tc.get("function", {})
tool_call_map[tc.get("id", "")] = {
"name": func.get("name", "unknown"),
"arguments": func.get("arguments", "{}")
}
tool_call_map[tc.get("id", "")] = {"name": func.get("name", "unknown"), "arguments": func.get("arguments", "{}")}

for msg in trajectory.get("messages", []):
role = msg.get("role", "unknown").upper()

if role == "USER":
lines.append(f"[USER]")
lines.append("[USER]")
lines.append(msg.get("content", ""))
lines.append("")

elif role == "ASSISTANT":
lines.append(f"[ASSISTANT]")
lines.append("[ASSISTANT]")
if include_thinking and msg.get("thinking"):
lines.append(f"<thinking>")
lines.append("<thinking>")
lines.append(msg["thinking"][:500] + "..." if len(msg.get("thinking", "")) > 500 else msg.get("thinking", ""))
lines.append("</thinking>")
lines.append("")
Expand All @@ -387,7 +364,7 @@ def format_trajectory_as_text(trajectory: dict, include_thinking: bool = True) -
args = json.dumps(args_obj, indent=4)
except (json.JSONDecodeError, TypeError):
pass
lines.append(f" Arguments:")
lines.append(" Arguments:")
for arg_line in args.split("\n"):
lines.append(f" {arg_line}")
lines.append("")
Expand All @@ -404,7 +381,7 @@ def format_trajectory_as_text(trajectory: dict, include_thinking: bool = True) -
content = json.dumps(content_obj, indent=2)
except (json.JSONDecodeError, TypeError):
pass
lines.append(f" Response:")
lines.append(" Response:")
for content_line in content.split("\n")[:50]: # Limit to 50 lines
lines.append(f" {content_line}")
if content.count("\n") > 50:
Expand All @@ -415,58 +392,19 @@ def format_trajectory_as_text(trajectory: dict, include_thinking: bool = True) -


def main():
parser = argparse.ArgumentParser(
description="Extract agent trajectories from Arize Phoenix"
)
parser.add_argument(
"--url",
default="http://localhost:6006",
help="Phoenix server URL"
)
parser.add_argument(
"--limit",
type=int,
default=100,
help="Maximum number of spans to fetch"
)
parser.add_argument(
"--include-errors",
action="store_true",
help="Include failed spans"
)
parser.add_argument(
"--no-clean",
action="store_true",
help="Don't clean system reminders from messages"
)
parser.add_argument(
"--output",
"-o",
help="Output file (default: stdout)"
)
parser.add_argument(
"--pretty",
action="store_true",
help="Pretty print JSON output"
)
parser.add_argument(
"--trace-id",
help="Filter to specific trace ID"
)
parser.add_argument(
"--text",
action="store_true",
help="Output as human-readable text instead of JSON"
)
parser = argparse.ArgumentParser(description="Extract agent trajectories from Arize Phoenix")
parser.add_argument("--url", default="http://localhost:6006", help="Phoenix server URL")
parser.add_argument("--limit", type=int, default=100, help="Maximum number of spans to fetch")
parser.add_argument("--include-errors", action="store_true", help="Include failed spans")
parser.add_argument("--no-clean", action="store_true", help="Don't clean system reminders from messages")
parser.add_argument("--output", "-o", help="Output file (default: stdout)")
parser.add_argument("--pretty", action="store_true", help="Pretty print JSON output")
parser.add_argument("--trace-id", help="Filter to specific trace ID")
parser.add_argument("--text", action="store_true", help="Output as human-readable text instead of JSON")

args = parser.parse_args()

trajectories = get_trajectories(
base_url=args.url,
limit=args.limit,
include_errors=args.include_errors,
clean=not args.no_clean
)
trajectories = get_trajectories(base_url=args.url, limit=args.limit, include_errors=args.include_errors, clean=not args.no_clean)

if args.trace_id:
trajectories = [t for t in trajectories if t["trace_id"] == args.trace_id]
Expand All @@ -477,11 +415,7 @@ def main():
if args.text:
output = "\n\n".join(format_trajectory_as_text(t) for t in trajectories)
else:
output = json.dumps(
trajectories,
indent=2 if args.pretty else None,
default=str
)
output = json.dumps(trajectories, indent=2 if args.pretty else None, default=str)

if args.output:
with open(args.output, "w") as f:
Expand Down
6 changes: 2 additions & 4 deletions kaizen/frontend/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,7 @@ def delete_namespace(

if not force:
entity_count = ns.num_entities or 0
confirm = typer.confirm(
f"Delete namespace '{namespace_id}' with {entity_count} entities?"
)
confirm = typer.confirm(f"Delete namespace '{namespace_id}' with {entity_count} entities?")
if not confirm:
console.print("[yellow]Cancelled.[/yellow]")
raise typer.Exit(0)
Expand Down Expand Up @@ -343,7 +341,7 @@ def sync_phoenix(
project=project,
)

console.print(f"[bold]Syncing from Phoenix[/bold]")
console.print("[bold]Syncing from Phoenix[/bold]")
console.print(f" URL: {syncer.phoenix_url}")
console.print(f" Project: {syncer.project}")
console.print(f" Namespace: {syncer.namespace_id}")
Expand Down
Loading