Fix output truncation bug, add language support for agent responses
Bug 1 — Output truncation:
_run_claude() was replacing raw stdout with parsed sub-field which
could be a dict (not string). run_agent() then saved dict.__repr__
to DB instead of full JSON. Fixed: _run_claude() always returns
string output; run_agent() ensures string before DB write.
Added tests: full_output_saved_to_db, dict_output_saved_as_json_string.
Bug 2 — Language support:
Added projects.language column (TEXT DEFAULT 'ru').
Auto-migration for existing DBs (ALTER TABLE ADD COLUMN).
context_builder passes language in project context.
format_prompt() appends "## Language\nALWAYS respond in {language}"
at the end of every prompt.
CLI: kin project add --language ru (default: ru).
Tests: language in prompt for ru/en, project creation, context.
112 tests, all passing. ~/.kin/kin.db migrated (vdol: language=ru).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
38c252fc1b
commit
c129cf9d95
7 changed files with 117 additions and 18 deletions
|
|
@ -65,12 +65,15 @@ def run_agent(
|
||||||
result = _run_claude(prompt, model=model, working_dir=working_dir)
|
result = _run_claude(prompt, model=model, working_dir=working_dir)
|
||||||
duration = int(time.monotonic() - start)
|
duration = int(time.monotonic() - start)
|
||||||
|
|
||||||
# Parse output
|
# Parse output — ensure output_text is always a string for DB storage
|
||||||
output_text = result.get("output", "")
|
raw_output = result.get("output", "")
|
||||||
|
if not isinstance(raw_output, str):
|
||||||
|
raw_output = json.dumps(raw_output, ensure_ascii=False)
|
||||||
|
output_text = raw_output
|
||||||
success = result["returncode"] == 0
|
success = result["returncode"] == 0
|
||||||
parsed_output = _try_parse_json(output_text)
|
parsed_output = _try_parse_json(output_text)
|
||||||
|
|
||||||
# Log to DB
|
# Log FULL output to DB (no truncation)
|
||||||
models.log_agent_run(
|
models.log_agent_run(
|
||||||
conn,
|
conn,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
|
|
@ -133,24 +136,24 @@ def _run_claude(
|
||||||
"returncode": 124,
|
"returncode": 124,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Try to extract structured data from JSON output
|
# Always preserve the full raw stdout
|
||||||
output = proc.stdout or ""
|
raw_stdout = proc.stdout or ""
|
||||||
result: dict[str, Any] = {
|
result: dict[str, Any] = {
|
||||||
"output": output,
|
"output": raw_stdout,
|
||||||
"error": proc.stderr if proc.returncode != 0 else None,
|
"error": proc.stderr if proc.returncode != 0 else None,
|
||||||
"returncode": proc.returncode,
|
"returncode": proc.returncode,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Parse JSON output from claude --output-format json
|
# Parse JSON wrapper from claude --output-format json
|
||||||
parsed = _try_parse_json(output)
|
# Extract metadata (tokens, cost) but keep output as the full content string
|
||||||
|
parsed = _try_parse_json(raw_stdout)
|
||||||
if isinstance(parsed, dict):
|
if isinstance(parsed, dict):
|
||||||
result["tokens_used"] = parsed.get("usage", {}).get("total_tokens")
|
result["tokens_used"] = parsed.get("usage", {}).get("total_tokens")
|
||||||
result["cost_usd"] = parsed.get("cost_usd")
|
result["cost_usd"] = parsed.get("cost_usd")
|
||||||
# The actual content is usually in result or content
|
# Extract the agent's actual response, converting to string if needed
|
||||||
if "result" in parsed:
|
content = parsed.get("result") or parsed.get("content")
|
||||||
result["output"] = parsed["result"]
|
if content is not None:
|
||||||
elif "content" in parsed:
|
result["output"] = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False)
|
||||||
result["output"] = parsed["content"]
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -98,12 +98,14 @@ def project():
|
||||||
@click.option("--tech-stack", callback=_parse_json, default=None, help='JSON array, e.g. \'["vue3","nuxt"]\'')
|
@click.option("--tech-stack", callback=_parse_json, default=None, help='JSON array, e.g. \'["vue3","nuxt"]\'')
|
||||||
@click.option("--status", default="active")
|
@click.option("--status", default="active")
|
||||||
@click.option("--priority", type=int, default=5)
|
@click.option("--priority", type=int, default=5)
|
||||||
|
@click.option("--language", default="ru", help="Response language for agents (ru, en, etc.)")
|
||||||
@click.pass_context
|
@click.pass_context
|
||||||
def project_add(ctx, id, name, path, tech_stack, status, priority):
|
def project_add(ctx, id, name, path, tech_stack, status, priority, language):
|
||||||
"""Add a new project."""
|
"""Add a new project."""
|
||||||
conn = ctx.obj["conn"]
|
conn = ctx.obj["conn"]
|
||||||
p = models.create_project(conn, id, name, path,
|
p = models.create_project(conn, id, name, path,
|
||||||
tech_stack=tech_stack, status=status, priority=priority)
|
tech_stack=tech_stack, status=status, priority=priority,
|
||||||
|
language=language)
|
||||||
click.echo(f"Created project: {p['id']} ({p['name']})")
|
click.echo(f"Created project: {p['id']} ({p['name']})")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,7 @@ def _slim_project(project: dict) -> dict:
|
||||||
"name": project["name"],
|
"name": project["name"],
|
||||||
"path": project["path"],
|
"path": project["path"],
|
||||||
"tech_stack": project.get("tech_stack"),
|
"tech_stack": project.get("tech_stack"),
|
||||||
|
"language": project.get("language", "ru"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -209,4 +210,13 @@ def format_prompt(context: dict, role: str, prompt_template: str | None = None)
|
||||||
sections.append(prev if isinstance(prev, str) else json.dumps(prev, ensure_ascii=False))
|
sections.append(prev if isinstance(prev, str) else json.dumps(prev, ensure_ascii=False))
|
||||||
sections.append("")
|
sections.append("")
|
||||||
|
|
||||||
|
# Language instruction — always last so it's fresh in context
|
||||||
|
proj = context.get("project")
|
||||||
|
language = proj.get("language", "ru") if proj else "ru"
|
||||||
|
_LANG_NAMES = {"ru": "Russian", "en": "English", "es": "Spanish", "de": "German", "fr": "French"}
|
||||||
|
lang_name = _LANG_NAMES.get(language, language)
|
||||||
|
sections.append(f"## Language")
|
||||||
|
sections.append(f"ALWAYS respond in {lang_name}. All summaries, analysis, comments, and recommendations must be in {lang_name}.")
|
||||||
|
sections.append("")
|
||||||
|
|
||||||
return "\n".join(sections)
|
return "\n".join(sections)
|
||||||
|
|
|
||||||
11
core/db.py
11
core/db.py
|
|
@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS projects (
|
||||||
pm_prompt TEXT,
|
pm_prompt TEXT,
|
||||||
claude_md_path TEXT,
|
claude_md_path TEXT,
|
||||||
forgejo_repo TEXT,
|
forgejo_repo TEXT,
|
||||||
|
language TEXT DEFAULT 'ru',
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -163,10 +164,20 @@ def get_connection(db_path: Path = DB_PATH) -> sqlite3.Connection:
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate(conn: sqlite3.Connection):
|
||||||
|
"""Run migrations for existing databases."""
|
||||||
|
# Check if language column exists on projects
|
||||||
|
cols = {r[1] for r in conn.execute("PRAGMA table_info(projects)").fetchall()}
|
||||||
|
if "language" not in cols:
|
||||||
|
conn.execute("ALTER TABLE projects ADD COLUMN language TEXT DEFAULT 'ru'")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection:
|
def init_db(db_path: Path = DB_PATH) -> sqlite3.Connection:
|
||||||
conn = get_connection(db_path)
|
conn = get_connection(db_path)
|
||||||
conn.executescript(SCHEMA)
|
conn.executescript(SCHEMA)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
_migrate(conn)
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,14 +50,15 @@ def create_project(
|
||||||
pm_prompt: str | None = None,
|
pm_prompt: str | None = None,
|
||||||
claude_md_path: str | None = None,
|
claude_md_path: str | None = None,
|
||||||
forgejo_repo: str | None = None,
|
forgejo_repo: str | None = None,
|
||||||
|
language: str = "ru",
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Create a new project and return it as dict."""
|
"""Create a new project and return it as dict."""
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""INSERT INTO projects (id, name, path, tech_stack, status, priority,
|
"""INSERT INTO projects (id, name, path, tech_stack, status, priority,
|
||||||
pm_prompt, claude_md_path, forgejo_repo)
|
pm_prompt, claude_md_path, forgejo_repo, language)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
(id, name, path, _json_encode(tech_stack), status, priority,
|
(id, name, path, _json_encode(tech_stack), status, priority,
|
||||||
pm_prompt, claude_md_path, forgejo_repo),
|
pm_prompt, claude_md_path, forgejo_repo, language),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
return get_project(conn, id)
|
return get_project(conn, id)
|
||||||
|
|
|
||||||
|
|
@ -131,3 +131,33 @@ class TestFormatPrompt:
|
||||||
ctx = build_context(conn, "VDOL-001", "analyst", "vdol")
|
ctx = build_context(conn, "VDOL-001", "analyst", "vdol")
|
||||||
prompt = format_prompt(ctx, "analyst") # No analyst.md exists
|
prompt = format_prompt(ctx, "analyst") # No analyst.md exists
|
||||||
assert "analyst" in prompt.lower()
|
assert "analyst" in prompt.lower()
|
||||||
|
|
||||||
|
def test_format_includes_language_ru(self, conn):
|
||||||
|
ctx = build_context(conn, "VDOL-001", "debugger", "vdol")
|
||||||
|
prompt = format_prompt(ctx, "debugger", "Debug.")
|
||||||
|
assert "## Language" in prompt
|
||||||
|
assert "Russian" in prompt
|
||||||
|
assert "ALWAYS respond in Russian" in prompt
|
||||||
|
|
||||||
|
def test_format_includes_language_en(self, conn):
|
||||||
|
# Update project language to en
|
||||||
|
conn.execute("UPDATE projects SET language='en' WHERE id='vdol'")
|
||||||
|
conn.commit()
|
||||||
|
ctx = build_context(conn, "VDOL-001", "debugger", "vdol")
|
||||||
|
prompt = format_prompt(ctx, "debugger", "Debug.")
|
||||||
|
assert "ALWAYS respond in English" in prompt
|
||||||
|
|
||||||
|
|
||||||
|
class TestLanguageInProject:
|
||||||
|
def test_project_has_language_default(self, conn):
|
||||||
|
p = models.get_project(conn, "vdol")
|
||||||
|
assert p["language"] == "ru"
|
||||||
|
|
||||||
|
def test_create_project_with_language(self, conn):
|
||||||
|
p = models.create_project(conn, "en-proj", "English Project", "/en",
|
||||||
|
language="en")
|
||||||
|
assert p["language"] == "en"
|
||||||
|
|
||||||
|
def test_context_carries_language(self, conn):
|
||||||
|
ctx = build_context(conn, "VDOL-001", "pm", "vdol")
|
||||||
|
assert ctx["project"]["language"] == "ru"
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,48 @@ class TestRunAgent:
|
||||||
assert len(logs) == 1
|
assert len(logs) == 1
|
||||||
assert logs[0]["project_id"] == "vdol"
|
assert logs[0]["project_id"] == "vdol"
|
||||||
|
|
||||||
|
@patch("agents.runner.subprocess.run")
|
||||||
|
def test_full_output_saved_to_db(self, mock_run, conn):
|
||||||
|
"""Bug fix: output_summary must contain the FULL output, not truncated."""
|
||||||
|
long_json = json.dumps({
|
||||||
|
"result": json.dumps({
|
||||||
|
"summary": "Security audit complete",
|
||||||
|
"findings": [{"title": f"Finding {i}", "severity": "HIGH"} for i in range(50)],
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.stdout = long_json
|
||||||
|
mock.stderr = ""
|
||||||
|
mock.returncode = 0
|
||||||
|
mock_run.return_value = mock
|
||||||
|
|
||||||
|
run_agent(conn, "security", "VDOL-001", "vdol")
|
||||||
|
|
||||||
|
logs = conn.execute("SELECT output_summary FROM agent_logs WHERE agent_role='security'").fetchall()
|
||||||
|
assert len(logs) == 1
|
||||||
|
output = logs[0]["output_summary"]
|
||||||
|
assert output is not None
|
||||||
|
assert len(output) > 1000 # Must not be truncated
|
||||||
|
# Should contain all 50 findings
|
||||||
|
assert "Finding 49" in output
|
||||||
|
|
||||||
|
@patch("agents.runner.subprocess.run")
|
||||||
|
def test_dict_output_saved_as_json_string(self, mock_run, conn):
|
||||||
|
"""When claude returns structured JSON, it must be saved as string."""
|
||||||
|
mock_run.return_value = _mock_claude_success({
|
||||||
|
"result": {"status": "ok", "files": ["a.py", "b.py"]},
|
||||||
|
})
|
||||||
|
|
||||||
|
result = run_agent(conn, "debugger", "VDOL-001", "vdol")
|
||||||
|
|
||||||
|
# output should be a string (JSON serialized), not a dict
|
||||||
|
assert isinstance(result["raw_output"], str)
|
||||||
|
|
||||||
|
logs = conn.execute("SELECT output_summary FROM agent_logs WHERE agent_role='debugger'").fetchall()
|
||||||
|
saved = logs[0]["output_summary"]
|
||||||
|
assert isinstance(saved, str)
|
||||||
|
assert "a.py" in saved
|
||||||
|
|
||||||
@patch("agents.runner.subprocess.run")
|
@patch("agents.runner.subprocess.run")
|
||||||
def test_previous_output_passed(self, mock_run, conn):
|
def test_previous_output_passed(self, mock_run, conn):
|
||||||
mock_run.return_value = _mock_claude_success({"result": "tests pass"})
|
mock_run.return_value = _mock_claude_success({"result": "tests pass"})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue