Skip to content

Commit 73e21ea

Browse files
committed
kin is now less dumb
1 parent f353777 commit 73e21ea

13 files changed

Lines changed: 345 additions & 67 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jobs:
2727
- name: Lint and Format Check
2828
run: |
2929
uv run black --check .
30-
uv run flake8 . --exclude .venv,migrations --ignore E501,F401
30+
uv run flake8 . --exclude .venv,migrations --ignore E501,F401,W503,W504
3131
3232
- name: Verify CLI Entrypoint
3333
run: PYTHONPATH=src uv run python -m kin.cli --help

setup.cfg

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[flake8]
2+
exclude = .venv,migrations,__pycache__,.git
3+
max-line-length = 120
4+
ignore = E501,F401,W503,W504

src/kin/agent_runner.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,33 @@ async def run_agent():
3131
if task_data.agent_type == agent.agent_type:
3232
print(f"Processing task: {task_data.msg_id}")
3333

34-
result_data = await agent.process(task_data)
35-
36-
res = TaskResult(
37-
workflow_id=task_data.workflow_id,
38-
node_id=task_data.node_id,
39-
status="COMPLETED",
40-
output=result_data.get("data", result_data),
34+
# publish RUNNING so CLI shows it immediately
35+
await r.xadd(
36+
f"results:{task_data.workflow_id}",
37+
{
38+
"data": TaskResult(
39+
node_id=task_data.node_id,
40+
status="RUNNING",
41+
output={"agent_type": agent.agent_type},
42+
).model_dump_json()
43+
},
4144
)
4245

46+
try:
47+
result_data = await agent.process(task_data)
48+
res = TaskResult(
49+
node_id=task_data.node_id,
50+
status="COMPLETED",
51+
output=result_data,
52+
)
53+
except Exception as e:
54+
res = TaskResult(
55+
node_id=task_data.node_id,
56+
status="FAILED",
57+
error=str(e),
58+
output={"agent_type": agent.agent_type},
59+
)
60+
4361
await r.xadd(
4462
f"results:{task_data.workflow_id}",
4563
{"data": res.model_dump_json()},

src/kin/agents/researcher/main.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,9 @@ def __init__(self, api_key: str, **kwargs):
3333
async def process(self, task: TaskMessage) -> dict:
3434
print(f"[*] RESEARCHER: Processing task -> {task.task_description[:50]}...")
3535

36-
# 1. Action: Search the web for real-time data
37-
queries = [
38-
"silicon wafer shipment growth 2026",
39-
"AI chip demand statistics 2026",
40-
"memory price forecast Q3 2026 semiconductor",
41-
]
42-
search_data = "\n\n".join(web_search(q) for q in queries)
36+
# Use task description as the search query (first 120 chars as a focused query)
37+
search_query = task.task_description[:120].strip()
38+
search_data = web_search(search_query, max_results=5)
4339
print(f"[DEBUG] DDG returned {len(search_data)} chars")
4440

4541
system_prompt = (
@@ -48,18 +44,23 @@ async def process(self, task: TaskMessage) -> dict:
4844
)
4945

5046
try:
51-
# 2. Reasoning: Synthesis via Gemini
52-
prompt = f"{system_prompt}\n\nTASK: {task.task_description}\n\nSEARCH DATA:\n{search_data}"
5347
response = await asyncio.to_thread(
5448
self.client.chat.completions.create,
5549
model=self.model,
5650
messages=[
5751
{"role": "system", "content": system_prompt},
58-
{"role": "user", "content": prompt},
52+
{
53+
"role": "user",
54+
"content": (
55+
f"TASK: {task.task_description}\n\n"
56+
f"SEARCH DATA:\n{search_data or 'No search results available.'}"
57+
),
58+
},
5959
],
6060
)
6161

6262
return {
63+
"agent_type": "researcher",
6364
"data": {
6465
"research_content": response.choices[0].message.content,
6566
"sources": ["DuckDuckGo Live Search", "Groq AI"],
@@ -72,6 +73,7 @@ async def process(self, task: TaskMessage) -> dict:
7273
raise # let Temporal retry after backoff
7374
# only fallback for non-quota errors
7475
return {
76+
"agent_type": "researcher",
7577
"data": {"research_content": search_data, "sources": ["DuckDuckGo"]},
7678
"status": "partial_success",
7779
}

src/kin/gateway/main.py

Lines changed: 57 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,72 @@
11
import os
2+
from contextlib import asynccontextmanager
3+
24
import redis.asyncio as redis
35
from dotenv import load_dotenv
46
from fastapi import FastAPI, HTTPException
57
from pydantic import BaseModel
68
from temporalio.client import Client
79
from temporalio.contrib.pydantic import pydantic_data_converter
10+
11+
from kin.models.schemas import TaskResult
12+
from kin.observability.logging import get_logger, setup_logging
813
from kin.orchestrator.executor.dag_workflow import KinDAGWorkflow
9-
from kin.models.schemas import DAGSpec, TaskNode, TaskResult
1014
from kin.orchestrator.planner import Planner
1115

1216
load_dotenv()
17+
setup_logging()
18+
log = get_logger("kin.gateway")
19+
20+
RESULTS_TTL_SEC = 3600
21+
22+
23+
@asynccontextmanager
24+
async def lifespan(app: FastAPI):
25+
log.info("Gateway starting up...")
26+
app.state.temporal = await Client.connect(
27+
"localhost:7233", data_converter=pydantic_data_converter
28+
)
29+
app.state.redis = redis.Redis(host="localhost", port=6379, decode_responses=True)
30+
log.info("Connected to Temporal and Redis")
31+
yield
32+
await app.state.redis.aclose()
33+
log.info("Gateway shut down cleanly")
1334

14-
app = FastAPI(title="Kin AI Gateway")
35+
36+
app = FastAPI(title="Kin AI Gateway", lifespan=lifespan)
1537

1638

1739
class WorkflowRequest(BaseModel):
1840
prompt: str
1941

2042

43+
# ---------------------------------------------------------------------------
44+
# Health check
45+
# ---------------------------------------------------------------------------
46+
@app.get("/healthz", tags=["ops"])
47+
async def health():
48+
"""Liveness probe — returns 200 when gateway is up and Redis is reachable."""
49+
try:
50+
await app.state.redis.ping()
51+
redis_ok = True
52+
except Exception:
53+
redis_ok = False
54+
return {"status": "ok", "redis": redis_ok}
55+
56+
57+
# ---------------------------------------------------------------------------
58+
# Workflow endpoints
59+
# ---------------------------------------------------------------------------
2160
@app.post("/v1/workflows")
2261
async def start_workflow(request: WorkflowRequest):
2362
try:
24-
client = await Client.connect(
25-
"localhost:7233", data_converter=pydantic_data_converter
26-
)
27-
28-
# NEW: use planner
2963
planner = Planner(api_key=os.getenv("GROQ_API_KEY"))
3064
dag = planner.plan(request.prompt)
31-
3265
dag_id = str(dag.workflow_id)
3366

34-
await client.start_workflow(
67+
log.info("Starting workflow dag_id=%s nodes=%d", dag_id, len(dag.nodes))
68+
69+
await app.state.temporal.start_workflow(
3570
KinDAGWorkflow.run,
3671
dag,
3772
id=dag_id,
@@ -44,29 +79,22 @@ async def start_workflow(request: WorkflowRequest):
4479
}
4580

4681
except Exception as e:
47-
print(f"Error starting workflow: {e}")
82+
log.error("Failed to start workflow: %s", e, exc_info=True)
4883
raise HTTPException(status_code=500, detail=str(e))
4984

5085

51-
# Initialize Redis client (ideally outside the function or in app state)
52-
redis_client = redis.Redis(host="localhost", port=6379, decode_responses=True)
53-
54-
5586
@app.get("/v1/workflows/{workflow_id}")
5687
async def get_status(workflow_id: str):
5788
try:
58-
client = await Client.connect("localhost:7233")
59-
handle = client.get_workflow_handle(workflow_id)
89+
handle = app.state.temporal.get_workflow_handle(workflow_id)
6090
desc = await handle.describe()
6191

6292
stream_key = f"results:{workflow_id}"
63-
raw_entries = await redis_client.xrange(stream_key)
64-
65-
final_results = {}
93+
raw_entries = await app.state.redis.xrange(stream_key)
6694

95+
final_results: dict = {}
6796
for _, entry in raw_entries:
6897
result = TaskResult.model_validate_json(entry["data"])
69-
7098
final_results[result.node_id] = {
7199
"status": result.status,
72100
"agent_type": (
@@ -76,18 +104,24 @@ async def get_status(workflow_id: str):
76104
"error": result.error,
77105
}
78106

107+
overall_status = desc.status.name
108+
109+
# Set TTL on result stream once workflow is terminal
110+
if overall_status in ("COMPLETED", "FAILED", "TERMINATED", "TIMED_OUT"):
111+
await app.state.redis.expire(stream_key, RESULTS_TTL_SEC)
112+
79113
return {
80114
"workflow_id": workflow_id,
81-
"status": desc.status.name,
115+
"status": overall_status,
82116
"results": final_results,
83117
}
84118

85119
except Exception as e:
120+
log.warning("get_status error for %s: %s", workflow_id, e)
86121
raise HTTPException(status_code=404, detail=str(e))
87122

88123

89124
if __name__ == "__main__":
90125
import uvicorn
91126

92-
# Use the string "kin.gateway.main:app" for hot-reloading support
93-
uvicorn.run(app, host="0.0.0.0", port=8000)
127+
uvicorn.run("kin.gateway.main:app", host="0.0.0.0", port=8000, reload=True)

src/kin/models/schemas.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class TaskNode(BaseModel):
1717
task_description: str
1818
input_from: List[str] = []
1919
dependencies: list[str] = []
20-
timeout_sec: int = 120
20+
timeout_sec: int = 600
2121
max_retries: int = 2
2222

2323

src/kin/observability/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from kin.observability.logging import get_logger, setup_logging
2+
3+
__all__ = ["get_logger", "setup_logging"]

src/kin/observability/logging.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""
2+
Centralized structured logging for Kin.
3+
Call setup_logging() once at process startup.
4+
"""
5+
6+
import logging
7+
import sys
8+
9+
10+
def setup_logging(level: str = "INFO") -> None:
11+
fmt = "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s"
12+
datefmt = "%Y-%m-%dT%H:%M:%S"
13+
14+
handler = logging.StreamHandler(sys.stdout)
15+
handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt))
16+
17+
root = logging.getLogger()
18+
root.handlers.clear()
19+
root.addHandler(handler)
20+
root.setLevel(getattr(logging, level.upper(), logging.INFO))
21+
22+
# suppress noisy third-party loggers
23+
for noisy in ("httpx", "httpcore", "temporalio", "uvicorn.access"):
24+
logging.getLogger(noisy).setLevel(logging.WARNING)
25+
26+
27+
def get_logger(name: str) -> logging.Logger:
28+
return logging.getLogger(name)

src/kin/orchestrator/executor/activities.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,13 @@ async def dispatch_task(self, node: TaskNode, workflow_id: str) -> dict:
8888
if result.node_id == node.id:
8989
if result.status == "FAILED":
9090
raise Exception(result.error)
91-
92-
return {
93-
"node_id": node.id,
94-
"agent_type": node.agent_type,
95-
"data": result.output,
96-
}
91+
if result.status == "COMPLETED":
92+
return {
93+
"node_id": node.id,
94+
"agent_type": node.agent_type,
95+
"data": result.output,
96+
}
97+
# RUNNING — keep polling
9798

9899
except asyncio.CancelledError:
99100
activity.logger.warning(

src/kin/orchestrator/executor/dag_workflow.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,21 @@ async def run(self, dag: DAGSpec) -> dict:
4242
for nid in ready:
4343
node = node_map[nid]
4444
if node.input_from:
45-
context = "\n".join(
46-
f"--- Output from {dep} ---\n{str(results.get(dep, ''))}"
47-
for dep in node.input_from
48-
)
45+
parts = []
46+
for dep in node.input_from:
47+
dep_result = results.get(dep, {})
48+
if isinstance(dep_result, dict):
49+
content = (
50+
dep_result.get("data", {}).get("research_content")
51+
or dep_result.get("markdown")
52+
or dep_result.get("research_content")
53+
or str(dep_result)
54+
)
55+
else:
56+
content = str(dep_result)
57+
parts.append("--- Research from " + dep + " ---\n" + content)
4958
node.task_description += (
50-
f"\n\nCONTEXT FROM PREVIOUS STEPS:\n{context}"
59+
"\n\nCONTEXT FROM PREVIOUS RESEARCH:\n" + "\n\n".join(parts)
5160
)
5261

5362
# dispatch ready nodes in parallel
@@ -56,7 +65,11 @@ async def run(self, dag: DAGSpec) -> dict:
5665
"dispatch_task",
5766
args=[node_map[nid], str(dag.workflow_id)],
5867
start_to_close_timeout=timedelta(seconds=node_map[nid].timeout_sec),
59-
retry_policy=RetryPolicy(maximum_attempts=1),
68+
retry_policy=RetryPolicy(
69+
maximum_attempts=3,
70+
initial_interval=timedelta(seconds=30),
71+
maximum_interval=timedelta(minutes=3),
72+
),
6073
)
6174
for nid in ready
6275
]

0 commit comments

Comments
 (0)