From c129cf9d95e1f2af5b5f30e9f083302fdb3c8d37 Mon Sep 17 00:00:00 2001 From: johnfrum1234 Date: Sun, 15 Mar 2026 14:39:33 +0200 Subject: [PATCH] Fix output truncation bug, add language support for agent responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- agents/runner.py | 29 +++++++++++++----------- cli/main.py | 6 +++-- core/context_builder.py | 10 +++++++++ core/db.py | 11 +++++++++ core/models.py | 7 +++--- tests/test_context_builder.py | 30 +++++++++++++++++++++++++ tests/test_runner.py | 42 +++++++++++++++++++++++++++++++++++ 7 files changed, 117 insertions(+), 18 deletions(-) diff --git a/agents/runner.py b/agents/runner.py index 705fe7c..aa7ee6f 100644 --- a/agents/runner.py +++ b/agents/runner.py @@ -65,12 +65,15 @@ def run_agent( result = _run_claude(prompt, model=model, working_dir=working_dir) duration = int(time.monotonic() - start) - # Parse output - output_text = result.get("output", "") + # Parse output — ensure output_text is always a string for DB storage + 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 parsed_output = _try_parse_json(output_text) - # Log to DB + # Log FULL output to DB (no truncation) models.log_agent_run( conn, project_id=project_id, @@ -133,24 +136,24 @@ def _run_claude( "returncode": 124, } - # Try to extract structured data from JSON output - output = proc.stdout or "" + # Always preserve the full raw stdout + raw_stdout = proc.stdout or "" result: dict[str, Any] = { - "output": output, + "output": raw_stdout, "error": proc.stderr if proc.returncode != 0 else None, "returncode": proc.returncode, } - # Parse JSON output from claude --output-format json - parsed = _try_parse_json(output) + # Parse JSON wrapper from claude --output-format json + # Extract metadata (tokens, cost) but keep output as the full content string + parsed = _try_parse_json(raw_stdout) if isinstance(parsed, dict): result["tokens_used"] = parsed.get("usage", {}).get("total_tokens") result["cost_usd"] = parsed.get("cost_usd") - # The actual content is usually in result or content - if "result" in parsed: - result["output"] = parsed["result"] - elif "content" in parsed: - result["output"] = parsed["content"] + # Extract the agent's actual response, converting to string if needed + content = parsed.get("result") or parsed.get("content") + if content is not None: + result["output"] = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False) return result diff --git a/cli/main.py b/cli/main.py index 7cea90f..e720020 100644 --- a/cli/main.py +++ b/cli/main.py @@ -98,12 +98,14 @@ def project(): @click.option("--tech-stack", callback=_parse_json, default=None, help='JSON array, e.g. \'["vue3","nuxt"]\'') @click.option("--status", default="active") @click.option("--priority", type=int, default=5) +@click.option("--language", default="ru", help="Response language for agents (ru, en, etc.)") @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.""" conn = ctx.obj["conn"] 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']})") diff --git a/core/context_builder.py b/core/context_builder.py index 9db1b3b..fad1313 100644 --- a/core/context_builder.py +++ b/core/context_builder.py @@ -109,6 +109,7 @@ def _slim_project(project: dict) -> dict: "name": project["name"], "path": project["path"], "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("") + # 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) diff --git a/core/db.py b/core/db.py index 32ae5ef..284c66c 100644 --- a/core/db.py +++ b/core/db.py @@ -20,6 +20,7 @@ CREATE TABLE IF NOT EXISTS projects ( pm_prompt TEXT, claude_md_path TEXT, forgejo_repo TEXT, + language TEXT DEFAULT 'ru', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -163,10 +164,20 @@ def get_connection(db_path: Path = DB_PATH) -> sqlite3.Connection: 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: conn = get_connection(db_path) conn.executescript(SCHEMA) conn.commit() + _migrate(conn) return conn diff --git a/core/models.py b/core/models.py index 4eef709..d7bb075 100644 --- a/core/models.py +++ b/core/models.py @@ -50,14 +50,15 @@ def create_project( pm_prompt: str | None = None, claude_md_path: str | None = None, forgejo_repo: str | None = None, + language: str = "ru", ) -> dict: """Create a new project and return it as dict.""" conn.execute( """INSERT INTO projects (id, name, path, tech_stack, status, priority, - pm_prompt, claude_md_path, forgejo_repo) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + pm_prompt, claude_md_path, forgejo_repo, language) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", (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() return get_project(conn, id) diff --git a/tests/test_context_builder.py b/tests/test_context_builder.py index 45f27d9..64bf732 100644 --- a/tests/test_context_builder.py +++ b/tests/test_context_builder.py @@ -131,3 +131,33 @@ class TestFormatPrompt: ctx = build_context(conn, "VDOL-001", "analyst", "vdol") prompt = format_prompt(ctx, "analyst") # No analyst.md exists 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" diff --git a/tests/test_runner.py b/tests/test_runner.py index 588d681..f1dd4cd 100644 --- a/tests/test_runner.py +++ b/tests/test_runner.py @@ -95,6 +95,48 @@ class TestRunAgent: assert len(logs) == 1 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") def test_previous_output_passed(self, mock_run, conn): mock_run.return_value = _mock_claude_success({"result": "tests pass"})