kunchi 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kunchi/__init__.py +8 -0
- kunchi/agents/__init__.py +4 -0
- kunchi/agents/cli/__init__.py +0 -0
- kunchi/agents/executor.py +155 -0
- kunchi/agents/fake.py +98 -0
- kunchi/agents/implementor_contract.py +3 -0
- kunchi/agents/parsing.py +129 -0
- kunchi/agents/planner_contract.py +21 -0
- kunchi/agents/prompts.py +57 -0
- kunchi/agents/registry.py +61 -0
- kunchi/agents/validator_contract.py +6 -0
- kunchi/auth/__init__.py +11 -0
- kunchi/auth/models.py +24 -0
- kunchi/auth/provider.py +15 -0
- kunchi/auth/providers/__init__.py +3 -0
- kunchi/auth/providers/memory.py +44 -0
- kunchi/cli/__init__.py +5 -0
- kunchi/cli/main.py +129 -0
- kunchi/config/__init__.py +21 -0
- kunchi/config/defaults.py +11 -0
- kunchi/config/harness_config.py +76 -0
- kunchi/config/resolver.py +51 -0
- kunchi/events/__init__.py +3 -0
- kunchi/events/bus.py +20 -0
- kunchi/examples/__init__.py +0 -0
- kunchi/examples/demo.py +47 -0
- kunchi/examples/manual/README.md +38 -0
- kunchi/examples/manual/__init__.py +1 -0
- kunchi/examples/manual/_helpers.py +19 -0
- kunchi/examples/manual/cli_agent_fake_binary.py +65 -0
- kunchi/examples/manual/cli_runner.py +29 -0
- kunchi/examples/manual/cursor_provider_argv.py +63 -0
- kunchi/examples/manual/cursor_smoke.py +75 -0
- kunchi/examples/manual/harness_fake.py +29 -0
- kunchi/examples/manual/inspect_plan.py +32 -0
- kunchi/examples/manual/list_plans.py +34 -0
- kunchi/examples/manual/load_profile_file.py +44 -0
- kunchi/examples/manual/mixed_config.py +43 -0
- kunchi/examples/manual/shell_validator.py +38 -0
- kunchi/examples/manual/trace.py +15 -0
- kunchi/examples/manual/trace_harness.py +25 -0
- kunchi/harness.py +193 -0
- kunchi/hello.py +3 -0
- kunchi/models/__init__.py +54 -0
- kunchi/models/agent.py +42 -0
- kunchi/models/artifact.py +8 -0
- kunchi/models/context.py +23 -0
- kunchi/models/events.py +159 -0
- kunchi/models/output.py +52 -0
- kunchi/models/plan.py +70 -0
- kunchi/models/report.py +20 -0
- kunchi/models/task.py +65 -0
- kunchi/models/validation.py +19 -0
- kunchi/plan/__init__.py +3 -0
- kunchi/plan/engine.py +139 -0
- kunchi/plan/parsing.py +191 -0
- kunchi/plan/resume.py +28 -0
- kunchi/profiles/__init__.py +7 -0
- kunchi/profiles/base.py +35 -0
- kunchi/profiles/cursor/__init__.py +3 -0
- kunchi/profiles/cursor/profile.py +51 -0
- kunchi/profiles/examples/cursor.yaml +33 -0
- kunchi/profiles/examples/minimal.json +32 -0
- kunchi/profiles/examples/minimal.yaml +20 -0
- kunchi/profiles/fake/__init__.py +3 -0
- kunchi/profiles/fake/profile.py +20 -0
- kunchi/profiles/loader.py +51 -0
- kunchi/profiles/registry.py +25 -0
- kunchi/project/__init__.py +5 -0
- kunchi/project/settings.py +178 -0
- kunchi/providers/__init__.py +7 -0
- kunchi/providers/agents/__init__.py +13 -0
- kunchi/providers/agents/base.py +52 -0
- kunchi/providers/agents/cli_agent.py +50 -0
- kunchi/providers/agents/cursor/__init__.py +3 -0
- kunchi/providers/agents/cursor/provider.py +188 -0
- kunchi/providers/agents/registry.py +23 -0
- kunchi/providers/agents/runner.py +102 -0
- kunchi/providers/agents/shell/__init__.py +3 -0
- kunchi/providers/agents/shell/agent.py +107 -0
- kunchi/providers/vcs/__init__.py +4 -0
- kunchi/providers/vcs/artifacts.py +15 -0
- kunchi/providers/vcs/base.py +23 -0
- kunchi/providers/vcs/git/__init__.py +3 -0
- kunchi/providers/vcs/git/backend.py +94 -0
- kunchi/providers/vcs/hg/__init__.py +3 -0
- kunchi/providers/vcs/hg/backend.py +31 -0
- kunchi/providers/vcs/registry.py +39 -0
- kunchi/state/__init__.py +5 -0
- kunchi/state/file.py +50 -0
- kunchi/state/memory.py +22 -0
- kunchi/state/store.py +13 -0
- kunchi/task/__init__.py +3 -0
- kunchi/task/engine.py +285 -0
- kunchi/task/scheduler.py +20 -0
- kunchi/tracing/__init__.py +31 -0
- kunchi/tracing/format.py +330 -0
- kunchi/tracing/monitor.py +190 -0
- kunchi/tracing/preview.py +7 -0
- kunchi/tracing/trace.py +59 -0
- kunchi/validation/__init__.py +4 -0
- kunchi/validation/feedback.py +44 -0
- kunchi/validation/output.py +99 -0
- kunchi-0.1.0.dist-info/METADATA +168 -0
- kunchi-0.1.0.dist-info/RECORD +108 -0
- kunchi-0.1.0.dist-info/WHEEL +4 -0
- kunchi-0.1.0.dist-info/entry_points.txt +3 -0
- kunchi-0.1.0.dist-info/licenses/LICENSE +21 -0
kunchi/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Kunchi — a simple coding harness."""
|
|
2
|
+
|
|
3
|
+
from kunchi.config import HarnessConfig
|
|
4
|
+
from kunchi.harness import Harness, default_harness_config
|
|
5
|
+
from kunchi.hello import hello
|
|
6
|
+
from kunchi.models import PlanStatus, RunReport
|
|
7
|
+
|
|
8
|
+
__all__ = ["Harness", "HarnessConfig", "PlanStatus", "RunReport", "default_harness_config", "hello"]
|
|
File without changes
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from kunchi.plan.parsing import PlanDraftParseError, parse_plan_draft
|
|
2
|
+
from kunchi.agents.prompts import PromptBuilder
|
|
3
|
+
from kunchi.agents.registry import AgentRegistry
|
|
4
|
+
from kunchi.config.resolver import ConfigResolver
|
|
5
|
+
from kunchi.events.bus import EventBus
|
|
6
|
+
from kunchi.models.agent import AgentInput, AgentResult, AgentRole, AgentSpec
|
|
7
|
+
from kunchi.models.context import PlanContext, TaskContext
|
|
8
|
+
from kunchi.models.events import AgentCompleted, AgentInvoked
|
|
9
|
+
from kunchi.models.plan import PlanDraft
|
|
10
|
+
from kunchi.models.task import FixRequest, Task
|
|
11
|
+
from kunchi.models.validation import ValidationResult
|
|
12
|
+
from kunchi.tracing.preview import preview_text
|
|
13
|
+
from kunchi.tracing.trace import (
|
|
14
|
+
invocation_from_metadata,
|
|
15
|
+
traced_agent_result,
|
|
16
|
+
traced_validation_result,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AgentExecutor:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
registry: AgentRegistry,
|
|
24
|
+
prompts: PromptBuilder,
|
|
25
|
+
resolver: ConfigResolver,
|
|
26
|
+
events: EventBus | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._registry = registry
|
|
29
|
+
self._prompts = prompts
|
|
30
|
+
self._resolver = resolver
|
|
31
|
+
self._events = events
|
|
32
|
+
|
|
33
|
+
async def invoke_planner(self, spec: AgentSpec, goal: str, context: PlanContext) -> PlanDraft:
|
|
34
|
+
agent = self._registry.resolve(spec)
|
|
35
|
+
prompt = self._prompts.build_planner_prompt(goal, context)
|
|
36
|
+
result = await self._invoke_agent(
|
|
37
|
+
agent,
|
|
38
|
+
AgentInput(role=AgentRole.PLANNER, prompt=prompt, context=context),
|
|
39
|
+
spec,
|
|
40
|
+
prompt,
|
|
41
|
+
context,
|
|
42
|
+
)
|
|
43
|
+
if not isinstance(result, AgentResult):
|
|
44
|
+
raise TypeError("Planner must return AgentResult")
|
|
45
|
+
return parse_plan_draft(result)
|
|
46
|
+
|
|
47
|
+
async def invoke_implementor(
|
|
48
|
+
self,
|
|
49
|
+
task: Task,
|
|
50
|
+
ctx: TaskContext,
|
|
51
|
+
fix: FixRequest | None = None,
|
|
52
|
+
) -> AgentResult:
|
|
53
|
+
agent = self._registry.resolve(task.implementor)
|
|
54
|
+
prompt = self._prompts.build_implementor_prompt(task, ctx, fix)
|
|
55
|
+
result = await self._invoke_agent(
|
|
56
|
+
agent,
|
|
57
|
+
AgentInput(role=AgentRole.IMPLEMENTOR, prompt=prompt, context=ctx),
|
|
58
|
+
task.implementor,
|
|
59
|
+
prompt,
|
|
60
|
+
ctx,
|
|
61
|
+
)
|
|
62
|
+
if not isinstance(result, AgentResult):
|
|
63
|
+
raise TypeError("Implementor must return AgentResult")
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
async def invoke_validator(
|
|
67
|
+
self,
|
|
68
|
+
task: Task,
|
|
69
|
+
validator: AgentSpec,
|
|
70
|
+
ctx: TaskContext,
|
|
71
|
+
implementor: AgentResult,
|
|
72
|
+
attempt: int,
|
|
73
|
+
) -> ValidationResult:
|
|
74
|
+
agent = self._registry.resolve(validator)
|
|
75
|
+
prompt = self._prompts.build_validator_prompt(task, validator, ctx, implementor)
|
|
76
|
+
ctx.metadata["attempt"] = attempt
|
|
77
|
+
result = await self._invoke_agent(
|
|
78
|
+
agent,
|
|
79
|
+
AgentInput(role=AgentRole.VALIDATOR, prompt=prompt, context=ctx),
|
|
80
|
+
validator,
|
|
81
|
+
prompt,
|
|
82
|
+
ctx,
|
|
83
|
+
)
|
|
84
|
+
if not isinstance(result, ValidationResult):
|
|
85
|
+
raise TypeError("Validator must return ValidationResult")
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
async def _invoke_agent(
|
|
89
|
+
self,
|
|
90
|
+
agent: object,
|
|
91
|
+
agent_input: AgentInput,
|
|
92
|
+
spec: AgentSpec,
|
|
93
|
+
prompt: str,
|
|
94
|
+
context: PlanContext | TaskContext,
|
|
95
|
+
) -> AgentResult | ValidationResult:
|
|
96
|
+
plan_id, task_id, attempt = self._context_ids(context)
|
|
97
|
+
preview_limit = self._resolver.trace_preview_chars()
|
|
98
|
+
|
|
99
|
+
await self._emit(
|
|
100
|
+
AgentInvoked(
|
|
101
|
+
agent_id=spec.id,
|
|
102
|
+
role=agent_input.role,
|
|
103
|
+
prompt=preview_text(prompt, preview_limit),
|
|
104
|
+
plan_id=plan_id,
|
|
105
|
+
task_id=task_id,
|
|
106
|
+
attempt=attempt,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
result = await agent.invoke(agent_input) # type: ignore[union-attr]
|
|
111
|
+
|
|
112
|
+
agent_result: AgentResult | None = None
|
|
113
|
+
validation_result: ValidationResult | None = None
|
|
114
|
+
invocation = None
|
|
115
|
+
|
|
116
|
+
if isinstance(result, AgentResult):
|
|
117
|
+
agent_result = traced_agent_result(result, preview_limit)
|
|
118
|
+
invocation = invocation_from_metadata(agent_result.metadata)
|
|
119
|
+
elif isinstance(result, ValidationResult):
|
|
120
|
+
validation_result = traced_validation_result(result, preview_limit)
|
|
121
|
+
invocation = invocation_from_metadata(validation_result.metadata)
|
|
122
|
+
|
|
123
|
+
await self._emit(
|
|
124
|
+
AgentCompleted(
|
|
125
|
+
agent_id=spec.id,
|
|
126
|
+
role=agent_input.role,
|
|
127
|
+
prompt=preview_text(prompt, preview_limit),
|
|
128
|
+
plan_id=plan_id,
|
|
129
|
+
task_id=task_id,
|
|
130
|
+
attempt=attempt,
|
|
131
|
+
agent_result=agent_result,
|
|
132
|
+
validation_result=validation_result,
|
|
133
|
+
invocation=invocation,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
return result
|
|
137
|
+
|
|
138
|
+
async def _emit(self, event: AgentInvoked | AgentCompleted) -> None:
|
|
139
|
+
if self._events is None:
|
|
140
|
+
return
|
|
141
|
+
await self._events.emit(event)
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def _context_ids(context: PlanContext | TaskContext) -> tuple[str | None, str | None, int | None]:
|
|
145
|
+
if not isinstance(context, TaskContext):
|
|
146
|
+
return None, None, None
|
|
147
|
+
metadata = context.metadata
|
|
148
|
+
plan_id = metadata.get("plan_id")
|
|
149
|
+
task_id = metadata.get("task_id")
|
|
150
|
+
attempt = metadata.get("attempt")
|
|
151
|
+
return (
|
|
152
|
+
str(plan_id) if plan_id is not None else None,
|
|
153
|
+
str(task_id) if task_id is not None else None,
|
|
154
|
+
int(attempt) if attempt is not None else None,
|
|
155
|
+
)
|
kunchi/agents/fake.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import Protocol
|
|
3
|
+
|
|
4
|
+
from kunchi.models.agent import AgentInput, AgentResult, AgentRole, AgentSpec
|
|
5
|
+
from kunchi.models.validation import ValidationResult
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Agent(Protocol):
|
|
9
|
+
spec: AgentSpec
|
|
10
|
+
|
|
11
|
+
async def invoke(self, input: AgentInput) -> AgentResult | ValidationResult: ...
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FakePlannerAgent:
|
|
15
|
+
def __init__(self, spec: AgentSpec) -> None:
|
|
16
|
+
self.spec = spec
|
|
17
|
+
|
|
18
|
+
async def invoke(self, input: AgentInput) -> AgentResult:
|
|
19
|
+
goal = input.prompt
|
|
20
|
+
if "invalid-plan" in goal.lower():
|
|
21
|
+
raw = "{not valid json"
|
|
22
|
+
else:
|
|
23
|
+
draft = {
|
|
24
|
+
"tasks": [
|
|
25
|
+
{
|
|
26
|
+
"title": "Setup foundation",
|
|
27
|
+
"description": f"Initial work for: {goal[:80]}",
|
|
28
|
+
"acceptance_criteria": ["Foundation exists"],
|
|
29
|
+
"depends_on": [],
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
"title": "Complete feature",
|
|
33
|
+
"description": "Finish the requested feature",
|
|
34
|
+
"acceptance_criteria": ["Feature works"],
|
|
35
|
+
"depends_on": ["task-1"],
|
|
36
|
+
},
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
raw = json.dumps(draft)
|
|
40
|
+
return AgentResult(
|
|
41
|
+
agent_id=self.spec.id,
|
|
42
|
+
role=AgentRole.PLANNER,
|
|
43
|
+
summary="Fake planner produced a two-task plan",
|
|
44
|
+
raw_output=raw,
|
|
45
|
+
metadata={"kind": "fake-planner"},
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class FakeImplementorAgent:
|
|
50
|
+
def __init__(self, spec: AgentSpec) -> None:
|
|
51
|
+
self.spec = spec
|
|
52
|
+
self._attempts: dict[str, int] = {}
|
|
53
|
+
|
|
54
|
+
async def invoke(self, input: AgentInput) -> AgentResult:
|
|
55
|
+
task_key = input.prompt[:64]
|
|
56
|
+
attempt = self._attempts.get(task_key, 0) + 1
|
|
57
|
+
self._attempts[task_key] = attempt
|
|
58
|
+
|
|
59
|
+
fail_output_attempts = int(self.spec.config.get("fail_output_attempts", 0))
|
|
60
|
+
if attempt <= fail_output_attempts:
|
|
61
|
+
raw = json.dumps({"summary": "missing required structure"})
|
|
62
|
+
else:
|
|
63
|
+
raw = json.dumps(
|
|
64
|
+
{
|
|
65
|
+
"summary": f"Implemented task (attempt {attempt})",
|
|
66
|
+
"result_code": "ok",
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
return AgentResult(
|
|
71
|
+
agent_id=self.spec.id,
|
|
72
|
+
role=AgentRole.IMPLEMENTOR,
|
|
73
|
+
summary=f"Fake implementor attempt {attempt}",
|
|
74
|
+
raw_output=raw,
|
|
75
|
+
metadata={"attempt": attempt, "kind": "fake-implementor"},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class FakeValidatorAgent:
|
|
80
|
+
def __init__(self, spec: AgentSpec) -> None:
|
|
81
|
+
self.spec = spec
|
|
82
|
+
|
|
83
|
+
async def invoke(self, input: AgentInput) -> ValidationResult:
|
|
84
|
+
fail_until_attempt = int(self.spec.config.get("fail_until_attempt", 0))
|
|
85
|
+
attempt = int(input.context.metadata.get("attempt", 1)) # type: ignore[union-attr]
|
|
86
|
+
|
|
87
|
+
if attempt <= fail_until_attempt:
|
|
88
|
+
return ValidationResult(
|
|
89
|
+
validator_id=self.spec.id,
|
|
90
|
+
passed=False,
|
|
91
|
+
feedback=f"{self.spec.id} rejected attempt {attempt}",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
return ValidationResult(
|
|
95
|
+
validator_id=self.spec.id,
|
|
96
|
+
passed=True,
|
|
97
|
+
feedback=f"{self.spec.id} approved",
|
|
98
|
+
)
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
IMPLEMENTOR_OUTPUT_INSTRUCTIONS = """Return ONLY a single JSON object matching the required output contract (no markdown fences, no commentary).
|
|
2
|
+
Use snake_case field names exactly as specified in the contract.
|
|
3
|
+
When git_commit is required, create a git commit with your changes before responding."""
|
kunchi/agents/parsing.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
|
|
4
|
+
from kunchi.models.validation import ValidationResult
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def extract_json_from_stdout(text: str) -> dict[str, object]:
|
|
8
|
+
"""Extract a JSON object from CLI stdout, tolerating leading/trailing noise."""
|
|
9
|
+
text = text.strip()
|
|
10
|
+
if not text:
|
|
11
|
+
raise ValueError("Empty stdout")
|
|
12
|
+
|
|
13
|
+
for line in text.splitlines():
|
|
14
|
+
line = line.strip()
|
|
15
|
+
if not line:
|
|
16
|
+
continue
|
|
17
|
+
try:
|
|
18
|
+
data = json.loads(line)
|
|
19
|
+
except json.JSONDecodeError:
|
|
20
|
+
continue
|
|
21
|
+
if isinstance(data, dict):
|
|
22
|
+
return data
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
data = json.loads(text)
|
|
26
|
+
if isinstance(data, dict):
|
|
27
|
+
return data
|
|
28
|
+
except json.JSONDecodeError:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
block = _find_json_object(text)
|
|
32
|
+
if block is None:
|
|
33
|
+
raise ValueError("No JSON object found in stdout")
|
|
34
|
+
data = json.loads(block)
|
|
35
|
+
if not isinstance(data, dict):
|
|
36
|
+
raise ValueError("Parsed JSON is not an object")
|
|
37
|
+
return data
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def unwrap_cursor_cli_text(data: dict[str, object]) -> str | None:
|
|
41
|
+
"""Extract assistant/plan text from a Cursor CLI JSON envelope."""
|
|
42
|
+
if data.get("type") == "result":
|
|
43
|
+
inner = data.get("result")
|
|
44
|
+
if isinstance(inner, str) and inner.strip():
|
|
45
|
+
return inner.strip()
|
|
46
|
+
if isinstance(inner, dict):
|
|
47
|
+
nested = extract_assistant_text(inner)
|
|
48
|
+
return nested or json.dumps(inner)
|
|
49
|
+
|
|
50
|
+
direct = extract_assistant_text(data)
|
|
51
|
+
if direct:
|
|
52
|
+
return direct
|
|
53
|
+
|
|
54
|
+
for key in ("text", "output", "content", "message"):
|
|
55
|
+
value = data.get(key)
|
|
56
|
+
if isinstance(value, str) and value.strip():
|
|
57
|
+
return value.strip()
|
|
58
|
+
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def extract_assistant_text(data: dict[str, object]) -> str | None:
|
|
63
|
+
message = data.get("message")
|
|
64
|
+
if isinstance(message, dict):
|
|
65
|
+
content = message.get("content")
|
|
66
|
+
if isinstance(content, list):
|
|
67
|
+
parts: list[str] = []
|
|
68
|
+
for item in content:
|
|
69
|
+
if isinstance(item, dict):
|
|
70
|
+
text = item.get("text")
|
|
71
|
+
if isinstance(text, str) and text.strip():
|
|
72
|
+
parts.append(text.strip())
|
|
73
|
+
if parts:
|
|
74
|
+
return "\n".join(parts)
|
|
75
|
+
text = message.get("text")
|
|
76
|
+
if isinstance(text, str) and text.strip():
|
|
77
|
+
return text.strip()
|
|
78
|
+
|
|
79
|
+
if "tasks" in data and isinstance(data.get("tasks"), list):
|
|
80
|
+
return json.dumps(data)
|
|
81
|
+
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _find_json_object(text: str) -> str | None:
|
|
86
|
+
start = text.find("{")
|
|
87
|
+
if start == -1:
|
|
88
|
+
return None
|
|
89
|
+
depth = 0
|
|
90
|
+
for index, char in enumerate(text[start:], start=start):
|
|
91
|
+
if char == "{":
|
|
92
|
+
depth += 1
|
|
93
|
+
elif char == "}":
|
|
94
|
+
depth -= 1
|
|
95
|
+
if depth == 0:
|
|
96
|
+
return text[start : index + 1]
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def extract_json_object_from_text(text: str) -> dict[str, object] | None:
|
|
101
|
+
"""Extract a JSON object from prose, fenced blocks, or CLI envelope text."""
|
|
102
|
+
text = text.strip()
|
|
103
|
+
if not text:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
return extract_json_from_stdout(text)
|
|
108
|
+
except ValueError:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
for block in re.findall(r"```(?:json)?\s*([\s\S]*?)```", text, flags=re.IGNORECASE):
|
|
112
|
+
try:
|
|
113
|
+
return extract_json_from_stdout(block)
|
|
114
|
+
except ValueError:
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_validation_result(data: dict[str, object], validator_id: str) -> ValidationResult:
|
|
121
|
+
passed = bool(data.get("passed", False))
|
|
122
|
+
feedback = str(data.get("feedback", ""))
|
|
123
|
+
if not passed and not feedback:
|
|
124
|
+
feedback = "Validation failed"
|
|
125
|
+
return ValidationResult(
|
|
126
|
+
validator_id=validator_id,
|
|
127
|
+
passed=passed,
|
|
128
|
+
feedback=feedback,
|
|
129
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
PLAN_DRAFT_JSON_EXAMPLE = """{
|
|
2
|
+
"tasks": [
|
|
3
|
+
{
|
|
4
|
+
"title": "Short task title",
|
|
5
|
+
"description": "What to implement",
|
|
6
|
+
"acceptance_criteria": ["Criterion one", "Criterion two"],
|
|
7
|
+
"depends_on": []
|
|
8
|
+
}
|
|
9
|
+
]
|
|
10
|
+
}"""
|
|
11
|
+
|
|
12
|
+
PLANNER_OUTPUT_INSTRUCTIONS = f"""Return ONLY a single JSON object matching this schema (no markdown fences, no commentary):
|
|
13
|
+
|
|
14
|
+
{PLAN_DRAFT_JSON_EXAMPLE}
|
|
15
|
+
|
|
16
|
+
Rules:
|
|
17
|
+
- "tasks" is required and must contain at least one task object.
|
|
18
|
+
- Each task requires "title" and "description" strings.
|
|
19
|
+
- Use snake_case field names exactly as shown.
|
|
20
|
+
- "depends_on" lists task titles or ids that must finish first (use [] when none).
|
|
21
|
+
- Keep the plan focused and actionable for the given goal."""
|
kunchi/agents/prompts.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from kunchi.agents.implementor_contract import IMPLEMENTOR_OUTPUT_INSTRUCTIONS
|
|
2
|
+
from kunchi.agents.planner_contract import PLANNER_OUTPUT_INSTRUCTIONS
|
|
3
|
+
from kunchi.agents.validator_contract import VALIDATOR_OUTPUT_INSTRUCTIONS
|
|
4
|
+
from kunchi.models.agent import AgentResult, AgentSpec
|
|
5
|
+
from kunchi.models.context import PlanContext, TaskContext
|
|
6
|
+
from kunchi.models.task import FixRequest, Task
|
|
7
|
+
from kunchi.models.validation import ValidationResult
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PromptBuilder:
|
|
11
|
+
def build_planner_prompt(self, goal: str, context: PlanContext) -> str:
|
|
12
|
+
return (
|
|
13
|
+
f"Goal: {goal}\n"
|
|
14
|
+
f"Workspace: {context.workspace.path}\n\n"
|
|
15
|
+
f"{PLANNER_OUTPUT_INSTRUCTIONS}"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
def build_implementor_prompt(
|
|
19
|
+
self,
|
|
20
|
+
task: Task,
|
|
21
|
+
ctx: TaskContext,
|
|
22
|
+
fix: FixRequest | None = None,
|
|
23
|
+
) -> str:
|
|
24
|
+
parts = [
|
|
25
|
+
f"Task: {task.title}",
|
|
26
|
+
f"Description: {task.description}",
|
|
27
|
+
task.output_contract.to_prompt_instructions(),
|
|
28
|
+
IMPLEMENTOR_OUTPUT_INSTRUCTIONS,
|
|
29
|
+
]
|
|
30
|
+
if task.acceptance_criteria:
|
|
31
|
+
parts.append("Acceptance criteria:\n- " + "\n- ".join(task.acceptance_criteria))
|
|
32
|
+
if ctx.upstream_outputs:
|
|
33
|
+
parts.append("Upstream completed tasks:")
|
|
34
|
+
for task_id, result in ctx.upstream_outputs.items():
|
|
35
|
+
parts.append(f"- {task_id}: {result.summary}")
|
|
36
|
+
if fix and fix.accumulated_feedback:
|
|
37
|
+
parts.append(f"Fix the following issues:\n{fix.accumulated_feedback}")
|
|
38
|
+
return "\n\n".join(parts)
|
|
39
|
+
|
|
40
|
+
def build_validator_prompt(
|
|
41
|
+
self,
|
|
42
|
+
task: Task,
|
|
43
|
+
validator: AgentSpec,
|
|
44
|
+
ctx: TaskContext,
|
|
45
|
+
implementor: AgentResult,
|
|
46
|
+
) -> str:
|
|
47
|
+
del ctx
|
|
48
|
+
field_lines = [f"- {key}: {value}" for key, value in implementor.fields.items()]
|
|
49
|
+
criteria = "\n- ".join(task.acceptance_criteria) or "(none)"
|
|
50
|
+
return (
|
|
51
|
+
f"Validator: {validator.id}\n"
|
|
52
|
+
f"Task: {task.title}\n"
|
|
53
|
+
f"Implementor summary: {implementor.summary}\n"
|
|
54
|
+
f"Implementor fields:\n" + "\n".join(field_lines) + "\n"
|
|
55
|
+
f"Acceptance criteria:\n- {criteria}\n\n"
|
|
56
|
+
f"{VALIDATOR_OUTPUT_INSTRUCTIONS}"
|
|
57
|
+
)
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
from kunchi.agents.fake import FakeImplementorAgent, FakePlannerAgent, FakeValidatorAgent
|
|
4
|
+
from kunchi.config.resolver import ConfigResolver
|
|
5
|
+
from kunchi.models.agent import AgentInput, AgentResult, AgentSpec, AgentRole
|
|
6
|
+
from kunchi.models.validation import ValidationResult
|
|
7
|
+
from kunchi.providers.agents.cli_agent import CLIAgent
|
|
8
|
+
from kunchi.providers.agents.registry import AgentClientRegistry
|
|
9
|
+
from kunchi.providers.agents.runner import CLIRunner
|
|
10
|
+
from kunchi.providers.agents.shell import ShellAgent
|
|
11
|
+
from kunchi.providers.vcs.artifacts import ArtifactCollector
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Agent(Protocol):
|
|
15
|
+
spec: AgentSpec
|
|
16
|
+
|
|
17
|
+
async def invoke(self, input: AgentInput) -> AgentResult | ValidationResult: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AgentRegistry:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
resolver: ConfigResolver,
|
|
24
|
+
runner: CLIRunner | None = None,
|
|
25
|
+
artifact_collector: ArtifactCollector | None = None,
|
|
26
|
+
agent_clients: AgentClientRegistry | None = None,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._resolver = resolver
|
|
29
|
+
self._runner = runner or CLIRunner()
|
|
30
|
+
self._artifact_collector = artifact_collector or ArtifactCollector()
|
|
31
|
+
self._agent_clients = agent_clients or AgentClientRegistry.default()
|
|
32
|
+
|
|
33
|
+
def resolve(self, spec: AgentSpec) -> Agent:
|
|
34
|
+
if spec.config.get("backend") == "fake":
|
|
35
|
+
return self._resolve_fake(spec)
|
|
36
|
+
|
|
37
|
+
cli_name = spec.config.get("cli")
|
|
38
|
+
if cli_name is None:
|
|
39
|
+
return self._resolve_fake(spec)
|
|
40
|
+
|
|
41
|
+
cli_name = str(cli_name)
|
|
42
|
+
if cli_name == "shell":
|
|
43
|
+
return ShellAgent(spec, self._runner, self._resolver)
|
|
44
|
+
|
|
45
|
+
provider = self._agent_clients.get(cli_name)
|
|
46
|
+
return CLIAgent(
|
|
47
|
+
spec,
|
|
48
|
+
provider,
|
|
49
|
+
self._runner,
|
|
50
|
+
self._artifact_collector,
|
|
51
|
+
self._resolver,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def _resolve_fake(self, spec: AgentSpec) -> Agent:
|
|
55
|
+
if spec.role is AgentRole.PLANNER:
|
|
56
|
+
return FakePlannerAgent(spec)
|
|
57
|
+
if spec.role is AgentRole.IMPLEMENTOR:
|
|
58
|
+
return FakeImplementorAgent(spec)
|
|
59
|
+
if spec.role is AgentRole.VALIDATOR:
|
|
60
|
+
return FakeValidatorAgent(spec)
|
|
61
|
+
raise ValueError(f"Unsupported fake agent role: {spec.role}")
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
VALIDATOR_OUTPUT_INSTRUCTIONS = """Return ONLY a single JSON object (no markdown fences, no commentary):
|
|
2
|
+
{"passed": true|false, "feedback": "short explanation"}
|
|
3
|
+
|
|
4
|
+
Rules:
|
|
5
|
+
- "passed" is required and must be a boolean.
|
|
6
|
+
- "feedback" is required when passed is false; may be brief when passed is true."""
|
kunchi/auth/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from kunchi.auth.models import AuthConfig, AuthToken, UserCredentials
|
|
2
|
+
from kunchi.auth.provider import AuthProvider
|
|
3
|
+
from kunchi.auth.providers.memory import InMemoryAuthProvider
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"AuthConfig",
|
|
7
|
+
"AuthProvider",
|
|
8
|
+
"AuthToken",
|
|
9
|
+
"InMemoryAuthProvider",
|
|
10
|
+
"UserCredentials",
|
|
11
|
+
]
|
kunchi/auth/models.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AuthConfig(BaseModel):
|
|
5
|
+
"""Configuration for authentication providers."""
|
|
6
|
+
|
|
7
|
+
secret_key: str = Field(min_length=1, description="Signing key for tokens")
|
|
8
|
+
token_ttl_seconds: int = Field(default=3600, ge=1)
|
|
9
|
+
algorithm: str = "HS256"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class UserCredentials(BaseModel):
|
|
13
|
+
"""Credentials supplied during login."""
|
|
14
|
+
|
|
15
|
+
username: str = Field(min_length=1)
|
|
16
|
+
password: str = Field(min_length=1)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthToken(BaseModel):
|
|
20
|
+
"""Issued authentication token."""
|
|
21
|
+
|
|
22
|
+
access_token: str
|
|
23
|
+
token_type: str = "bearer"
|
|
24
|
+
expires_in: int | None = None
|
kunchi/auth/provider.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from typing import Protocol
|
|
2
|
+
|
|
3
|
+
from kunchi.auth.models import AuthToken, UserCredentials
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AuthProvider(Protocol):
|
|
7
|
+
"""Contract for authentication backends."""
|
|
8
|
+
|
|
9
|
+
async def authenticate(self, credentials: UserCredentials) -> AuthToken | None:
|
|
10
|
+
"""Validate credentials and return a token, or None if invalid."""
|
|
11
|
+
...
|
|
12
|
+
|
|
13
|
+
async def verify_token(self, token: str) -> str | None:
|
|
14
|
+
"""Return the authenticated subject for a valid token, or None."""
|
|
15
|
+
...
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
from datetime import UTC, datetime, timedelta
|
|
3
|
+
|
|
4
|
+
from kunchi.auth.models import AuthConfig, AuthToken, UserCredentials
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InMemoryAuthProvider:
|
|
8
|
+
"""Simple in-memory auth backend for development and tests."""
|
|
9
|
+
|
|
10
|
+
def __init__(
|
|
11
|
+
self,
|
|
12
|
+
config: AuthConfig,
|
|
13
|
+
users: dict[str, str] | None = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
self._config = config
|
|
16
|
+
self._users = dict(users or {})
|
|
17
|
+
self._tokens: dict[str, tuple[str, datetime]] = {}
|
|
18
|
+
|
|
19
|
+
def register_user(self, username: str, password: str) -> None:
|
|
20
|
+
self._users[username] = password
|
|
21
|
+
|
|
22
|
+
async def authenticate(self, credentials: UserCredentials) -> AuthToken | None:
|
|
23
|
+
if self._users.get(credentials.username) != credentials.password:
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
token = secrets.token_urlsafe(32)
|
|
27
|
+
expires_at = datetime.now(UTC) + timedelta(seconds=self._config.token_ttl_seconds)
|
|
28
|
+
self._tokens[token] = (credentials.username, expires_at)
|
|
29
|
+
return AuthToken(
|
|
30
|
+
access_token=token,
|
|
31
|
+
expires_in=self._config.token_ttl_seconds,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
async def verify_token(self, token: str) -> str | None:
|
|
35
|
+
entry = self._tokens.get(token)
|
|
36
|
+
if entry is None:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
username, expires_at = entry
|
|
40
|
+
if datetime.now(UTC) >= expires_at:
|
|
41
|
+
del self._tokens[token]
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
return username
|