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
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,14 +53,14 @@ All configuration variables are prefixed with `KAIZEN_`.

**General Settings:**

| Variable | Description | Default |
|----------|-------------|---------|
| `KAIZEN_PROVIDER` | Backend provider (`milvus` or `filesystem`) | `milvus` |
| `KAIZEN_NAMESPACE_ID` | Namespace ID for isolation | `kaizen` |
| `KAIZEN_TIPS_MODEL` | Model for generating tips | `openai/gpt-4o` |
| `KAIZEN_CONFLICT_RESOLUTION_MODEL` | Model for resolving conflicts | `openai/gpt-4o` |
| `KAIZEN_CUSTOM_LLM_PROVIDER` | LiteLLM provider (use `openai` for proxy with custom models) | `openai` |
| `KAIZEN_EMBEDDING_MODEL` | Embedding model | `sentence-transformers/all-MiniLM-L6-v2` |
| Variable | Description | Default |
|----------|-------------------------------------------------------------------------------|------------------------------------------|
| `KAIZEN_PROVIDER` | Backend provider (`milvus` or `filesystem`) | `milvus` |
| `KAIZEN_NAMESPACE_ID` | Namespace ID for isolation | `kaizen` |
| `KAIZEN_TIPS_MODEL` | Model for generating tips (e.g. `openai/gpt-4o` for proxy with custom models) | `gpt-4o` |
| `KAIZEN_CONFLICT_RESOLUTION_MODEL` | Model for resolving conflicts (e.g. `openai/gpt-4o` for proxy with custom models) | `gpt-4o` |
| `KAIZEN_CUSTOM_LLM_PROVIDER` | LiteLLM provider (use `openai` for proxy with custom models) | `None` |
| `KAIZEN_EMBEDDING_MODEL` | Embedding model | `sentence-transformers/all-MiniLM-L6-v2` |

**Milvus Backend Settings** (when `KAIZEN_PROVIDER=milvus`):

Expand Down
13 changes: 8 additions & 5 deletions kaizen/config/llm.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class LLMSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix='KAIZEN_')
tips_model: str = "openai/gpt-4o"
conflict_resolution_model: str = "openai/gpt-4o"
custom_llm_provider: str = "openai"
model_config = SettingsConfigDict(env_prefix="KAIZEN_")
tips_model: str = "gpt-4o"
conflict_resolution_model: str = "gpt-4o"
custom_llm_provider: str | None = Field(default=None)


# to reload settings call llm_settings.__init__()
llm_settings = LLMSettings()
llm_settings = LLMSettings()
53 changes: 35 additions & 18 deletions kaizen/frontend/mcp/mcp_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

This server provides a tool to get task-relevant guidelines.
"""

import json
import logging
import uuid
Expand All @@ -20,12 +21,14 @@
mcp = FastMCP("katas")
client = KaizenClient()


def ensure_namespace():
try:
client.get_namespace_details(kaizen_config.namespace_id)
except NamespaceNotFoundException:
client.create_namespace(kaizen_config.namespace_id)


@mcp.tool()
def get_guidelines(task: str) -> str:
"""
Expand All @@ -41,7 +44,7 @@ def get_guidelines(task: str) -> str:
results = client.search_entities(
namespace_id=kaizen_config.namespace_id,
query=task,
filters={"type": "guideline"}
filters={"type": "guideline"},
)

# Format the response
Expand All @@ -54,7 +57,9 @@ def get_guidelines(task: str) -> str:


@mcp.tool()
def save_trajectory(trajectory_data: str, task_id: str | None = None) -> list[RecordedEntity]:
def save_trajectory(
trajectory_data: str, task_id: str | None = None
) -> list[RecordedEntity]:
"""
Save the full agent trajectory to the Kata DB and generate tips

Expand All @@ -67,33 +72,45 @@ def save_trajectory(trajectory_data: str, task_id: str | None = None) -> list[Re
entities = []
messages = json.loads(trajectory_data)
for message in messages:
entities.append(Entity(
type='trajectory',
content=message['content'] if isinstance(message['content'], str) else str(message['content']),
metadata={
"task_id": task_id,
"message": message # store the original message for reference
}
))
entities.append(
Entity(
type="trajectory",
content=message["content"]
if isinstance(message["content"], str)
else str(message["content"]),
metadata={
"task_id": task_id,
"message": message, # store the original message for reference
},
)
)

client.update_entities(
namespace_id=kaizen_config.namespace_id,
entities=entities,
enable_conflict_resolution=False
enable_conflict_resolution=False,
)
tips = generate_tips(messages)

client.update_entities(
namespace_id=kaizen_config.namespace_id,
entities=[Entity(
type='guideline',
content=tip,
) for tip in tips],
enable_conflict_resolution=True
entities=[
Entity(
type="guideline",
content=tip.content,
metadata={
"category": tip.category,
"rationale": tip.rationale,
"trigger": tip.trigger,
},
)
for tip in tips
],
enable_conflict_resolution=True,
)

return client.search_entities(
namespace_id=kaizen_config.namespace_id,
filters={"type": "trajectory", "task_id": task_id},
limit=1000
)
limit=1000,
)
41 changes: 36 additions & 5 deletions kaizen/llm/tips/prompts/generate_tips.jinja2
Original file line number Diff line number Diff line change
@@ -1,7 +1,38 @@
Examine the following agent trajectory. Extract guidelines or tips that can be used in the future to optimize the trajectory.
This includes remembering sequences of tool calls, or entities that will make the reasoning more efficient and avoid errors.
You are analyzing an AI agent's execution trajectory to extract actionable tips.

The tips should be short sentences that guide future iterations of the agent to perform better or faster, avoid errors, or learn from experience.
Generate as many tips as you think is needed. Output as a JSON formatted list of strings.
# Task Information
**Task:** {{task_instruction}}
**Status:** UNKNOWN
**Steps Taken:** {{num_steps}}

{{markdown_trajectory}}
# Agent Trajectory
{{trajectory_summary}}

# Your Task
Extract 3-5 actionable tips from this trajectory that would help AI agents perform similar tasks better.

**Guidelines:**
1. Focus on patterns that worked or mistakes that were made
2. Be specific to what you observed in this trajectory
3. Each tip should have:
- Clear description of what to do (or avoid)
- Why it matters
- When to apply it

{% if not constrained_decoding_supported %}
**Output Format (JSON):**
```json
{
"tips": [
{
"content": "Clear, actionable tip",
"rationale": "Why this tip helps",
"category": "strategy|recovery|optimization",
"trigger": "When to apply this tip"
}
]
}
```

Generate tips now. Return ONLY the JSON, no other text.
{% endif %}
192 changes: 148 additions & 44 deletions kaizen/llm/tips/tips.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,158 @@
import json
from json import JSONDecodeError

import litellm

from jinja2 import Template
from litellm import completion
from litellm import completion, get_supported_openai_params, supports_response_schema
from kaizen.config.llm import llm_settings
from kaizen.utils.utils import clean_llm_response
from kaizen.schema.exceptions import KaizenException
from kaizen.schema.tips import TipGenerationResponse, Tip
from pathlib import Path

def generate_tips(messages: list[dict]) -> list[str]:
markdown_trajectory = messages_to_markdown(messages)
prompt_file = Path(__file__).parent / "prompts/generate_tips.jinja2"
prompt = Template(prompt_file.read_text()).render(markdown_trajectory=markdown_trajectory)
response = completion(
model=llm_settings.tips_model,
messages=[{"role": "user", "content": prompt}],
custom_llm_provider=llm_settings.custom_llm_provider
).choices[0].message.content
clean_response = clean_llm_response(response)
return json.loads(clean_response)

def messages_to_markdown(messages: list[dict]) -> str:
def parse_openai_agents_trajectory(messages: list[dict]) -> dict:
"""
Convert a list of OpenAI-format messages to a Markdown string.
Parse OpenAI Agents SDK trajectory from streamer.to_input_list().

Returns:
dict with:
- task_instruction: The task description
- agent_steps: List of agent reasoning/actions
- function_calls: List of tool/function calls made
- num_steps: Total number of agent actions
"""
md_lines = []

for msg in messages:
role = msg.get("role", "unknown").title()
content = msg.get("content", "")

md_lines.append(f"## {role}")

if isinstance(content, str):
md_lines.append(content)
elif isinstance(content, list):
for block in content:
if isinstance(block, dict):
if block.get("type") == "text":
md_lines.append(block.get("text", ""))
elif block.get("type") == "function_call":
func = block.get("function", {})
md_lines.append(f"**Tool Call**: `{func.get('name')}`")
md_lines.append("```json")
md_lines.append(func.get("arguments", ""))
md_lines.append("```")
elif block.get("type") == "function_response":
md_lines.append(f"**Tool Result** ({block.get('id')}):")
md_lines.append("```")
md_lines.append(block.get("content", ""))
md_lines.append("```")

md_lines.append("") # Empty line between messages

return "\n".join(md_lines)
agent_steps = []
function_calls = []
task_instruction = None

for message in messages:
# Extract task instruction from first user message
if message.get("role") == "user" and task_instruction is None:
if isinstance(message["content"], str):
task_instruction = message["content"]
else:
raise KaizenException("First user message was not a task instruction.")

# Extract assistant reasoning/messages
if message.get("role") == "assistant":
content = message.get("content", "")
if isinstance(content, str) and content.strip():
agent_steps.append(
{"type": "reasoning", "content": content, "raw": message}
)

# Extract function calls
elif isinstance(content, list):
for assistant_response in content:
if assistant_response["type"] == "function_call":
function_call = {
"type": "function_call",
"name": assistant_response["function"]["name"],
"arguments": assistant_response["function"]["arguments"],
"call_id": assistant_response["id"],
"raw": assistant_response,
}
function_calls.append(function_call)

# Add to agent steps as an action
args_str = assistant_response["function"]["arguments"]
try:
args: dict = json.loads(args_str)
args_display = ", ".join(
f"{k}={json.dumps(v)}" for k, v in args.items()
)
function_description = f"{assistant_response['function']['name']}({args_display})"
except JSONDecodeError:
function_description = (
f"{assistant_response['function']['name']}({args_str})"
)

agent_steps.append(
{
"type": "action",
"content": function_description,
"raw": assistant_response,
}
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
else:
raise KaizenException(
f"Unhandled assistant content type in list `{assistant_response['type']}`"
)
else:
raise KaizenException(
f"Unhandled assistant content type `{type(content)}`"
)

steps_text = []
for i, step in enumerate(agent_steps[:50], 1):
step_type = step["type"]
content = step["content"]
# Truncate long content
if len(content) > 2000:
content = content[:2000] + "..."

if step_type == "reasoning":
steps_text.append(f"**Step {i} - Reasoning:**\n{content}")
elif step_type == "action":
steps_text.append(f"**Step {i} - Action:**\n{content}")
elif step_type == "observation":
steps_text.append(f"**Step {i} - Observation:**\n{content}")

return {
"task_instruction": task_instruction or "Unknown task",
"trajectory_summary": "\n\n".join(steps_text),
"function_calls": function_calls,
"num_steps": len(
[s for s in agent_steps if s["type"] in ["action", "reasoning"]]
),
}


def generate_tips(messages: list[dict]) -> list[Tip]:
prompt_file = Path(__file__).parent / "prompts/generate_tips.jinja2"
supports_response_format = "response_format" in get_supported_openai_params(
model=llm_settings.tips_model,
custom_llm_provider=llm_settings.custom_llm_provider,
)
response_schema_enabled = supports_response_schema(
model=llm_settings.tips_model,
custom_llm_provider=llm_settings.custom_llm_provider,
)
constrained_decoding_supported = (
supports_response_format and response_schema_enabled
)
trajectory_data = parse_openai_agents_trajectory(messages)
prompt = Template(prompt_file.read_text()).render(
task_instruction=trajectory_data["task_instruction"],
num_steps=trajectory_data["num_steps"],
trajectory_summary=trajectory_data["trajectory_summary"],
constrained_decoding_supported=constrained_decoding_supported,
)

Comment on lines +126 to +133

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new tips prompt made by jayaram wants us to preprocess the openai trajectory directly. This implementation differs slightly from his in that the data format we expect is different from the one he used. Apparently OpenAI's format is not a standard.

if constrained_decoding_supported:
litellm.enable_json_schema_validation = True
clean_response = (
completion(
model=llm_settings.tips_model,
messages=[{"role": "user", "content": prompt}],
response_format=TipGenerationResponse,
custom_llm_provider=llm_settings.custom_llm_provider,
)
.choices[0]
.message.content
)
else:
litellm.enable_json_schema_validation = False
Comment on lines +134 to +147

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the exact file and lines in question
find . -name "tips.py" -type f | head -5

Repository: AgentToolkit/kaizen

Length of output: 112


🌐 Web query:

litellm enable_json_schema_validation per-request configuration thread safety

💡 Result:

Short answer: litellm.enable_json_schema_validation is a global/module-level flag (not a per-call parameter) and therefore is not safe to toggle concurrently from multiple threads for per-request behavior. Use per-request validation controls (where available) or avoid mutating the global flag at runtime. [1][2]

Details and evidence:

  • The official config docs show enable_json_schema_validation is a boolean in the global settings and “If true, enables json schema validation for all requests.” — i.e., module-level/global. [1]
  • There is an open/closed feature request asking to convert litellm.enable_json_schema_validation into a per-call parameter; the issue confirms the current behavior is global and the request was closed as “not planned” (so no built-in per-request flag yet). This implies toggling the global at runtime is the only built-in option today. [2]
  • Some providers/features support per-call enforcement (e.g., response_format/enforce_validation for Vertex provider), so prefer using per-request enforcement where the provider SDK supports it instead of changing the global flag. [3]

Recommended practical approaches:

  • Don’t flip litellm.enable_json_schema_validation inside concurrent threads. Treat it as a process-wide setting.
  • If you need per-request control:
    • Use provider-specific per-request options (response_format with enforce_validation / structured-output options) when available. [3]
    • Run separate worker processes each with the desired global setting (process isolation).
    • If you must toggle the global flag in a single process, serialize changes with a lock so only one thread modifies/uses it at a time (but this serializes those requests and is brittle).

Sources:

  1. liteLLM config settings — enable_json_schema_validation description. [link] [1]
  2. GitHub issue requesting per-call parameter for enable_json_schema_validation (feature request, closed/not planned). [link] [2]
  3. liteLLM Structured Outputs / per-request enforce_validation example (Vertex provider). [link] [3]

Would you like code examples for a locking approach or a per-process worker setup?


🏁 Script executed:

# Examine the tips.py file around lines 113-158 to see full context
head -160 ./kaizen/llm/tips/tips.py | tail -60

Repository: AgentToolkit/kaizen

Length of output: 2455


🏁 Script executed:

# Get full context of the function to understand imports and setup
head -160 ./kaizen/llm/tips/tips.py | cat -n

Repository: AgentToolkit/kaizen

Length of output: 7588


🏁 Script executed:

# Search for calls to generate_tips to understand usage patterns
rg "generate_tips" --type py

Repository: AgentToolkit/kaizen

Length of output: 2638


🌐 Web query:

litellm response_format Pydantic model automatic validation enable_json_schema_validation

💡 Result:

Short answer: LiteLLM supports using a Pydantic BaseModel as response_format and can perform client‑side JSON Schema validation, but validation is enabled globally via litellm.enable_json_schema_validation (module setting) and there are known edge cases when converting Pydantic -> JSON Schema so some users convert models manually before passing them.

How to use it

  • Pass a Pydantic model as response_format to completion(...). Example in the docs shows passing a BaseModel class directly. [1]
  • To enable client-side validation, set litellm.enable_json_schema_validation = True (this uses jsonvalidator to validate LLM JSON responses). [1][4]

Known caveats / workarounds

  • Validation is currently a global/module setting (not a per-call parameter). There’s an open feature request to make it per-call (closed/not planned). [2]
  • Some users have observed buggy or imprecise conversion from Pydantic models to the JSON schema LiteLLM sends to models; recommended workaround is to generate the schema yourself (e.g., model.model_json_schema()) and pass a {"type":"json_schema","json_schema":...} dict instead of relying on the built-in conversion. See related bug reports/discussions. [3]

Sources

  • LiteLLM docs: Structured Outputs / JSON mode. [1]
  • LiteLLM config settings (enable_json_schema_validation). [4]
  • GitHub issues (feature request to make validation per-call; Pydantic -> JSON Schema conversion bugs). [2][3]

Thread-safety concern: Global state mutation.

Setting litellm.enable_json_schema_validation globally on lines 135 and 147 can cause race conditions in concurrent/multi-threaded environments where multiple calls to generate_tips could interfere with each other. LiteLLM's enable_json_schema_validation is a module-level flag that cannot be toggled safely per-request, making this pattern unsafe for concurrent use.

🔧 Suggested approach

Consider one of these alternatives:

  1. Use a lock to serialize access to this global state
  2. Set this once at application startup rather than per-call
  3. Use process isolation (separate workers each with their own global setting)
+import threading
+
+_litellm_lock = threading.Lock()
+
 def generate_tips(messages: list[dict]) -> list[Tip]:
     # ... setup code ...
     
     if constrained_decoding_supported:
+        with _litellm_lock:
+            litellm.enable_json_schema_validation = True
+            clean_response = (
+                completion(
-        litellm.enable_json_schema_validation = True
-        clean_response = (
-            completion(
                 model=llm_settings.tips_model,
                 messages=[{"role": "user", "content": prompt}],
                 response_format=TipGenerationResponse,
                 custom_llm_provider=llm_settings.custom_llm_provider,
             )
             .choices[0]
             .message.content
+            )
         )
     else:
+        with _litellm_lock:
+            litellm.enable_json_schema_validation = False
+            # ... completion call ...
-        litellm.enable_json_schema_validation = False
-        response = (
-            completion(
-                ...
-            )
-        )

Alternatively, if this function is only called single-threaded, document that assumption explicitly.

🤖 Prompt for AI Agents
In `@kaizen/llm/tips/tips.py` around lines 134 - 147, The function generate_tips
toggles the module-level flag litellm.enable_json_schema_validation per-call
(controlled by constrained_decoding_supported), which is not thread-safe; either
remove per-call mutation and ensure the flag is set once at process startup (set
litellm.enable_json_schema_validation based on desired global behavior) or
serialize access by guarding the toggling + call to completion(...,
response_format=TipGenerationResponse) with a thread/process-safe lock (e.g., a
module-level threading.Lock) so only one request mutates the flag at a time;
update generate_tips to reference constrained_decoding_supported and
completion/TipGenerationResponse without directly flipping the global flag per
request, or document that the function is single-threaded if you choose not to
change behavior.

response = (
completion(
model=llm_settings.tips_model,
messages=[{"role": "user", "content": prompt}],
custom_llm_provider=llm_settings.custom_llm_provider,
)
.choices[0]
.message.content
)
clean_response = clean_llm_response(response)
Comment on lines +134 to +157

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use constrained decoding if available (litellm's "json_mode") which guarantees a JSON output, ensuring a valid response without requiring retries or other messy error handling/retry logic.

return TipGenerationResponse.model_validate(json.loads(clean_response)).tips
Loading