Skip to content

Commit 2fdf8f1

Browse files
author
v.chetvertukhin
committed
feat(samples): add multitenant-agent-k8s auto-agent — BYO LangGraph ReAct agent with HITL
1 parent 549c0d7 commit 2fdf8f1

File tree

15 files changed

+3509
-2804
lines changed

15 files changed

+3509
-2804
lines changed

python/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[tool.uv.workspace]
2-
members = ["packages/*", "samples/adk/*", "samples/langgraph/*", "samples/crewai/*", "samples/openai/*"]
2+
members = ["packages/*", "samples/adk/*", "samples/langgraph/*", "samples/crewai/*", "samples/openai/*", "samples/multitenant-agent-k8s/*"]
33

44
[dependency-groups]
55
dev = [
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Auto Agent — Phase 0.9
2+
3+
Autonomous AI assistant with dynamic tool discovery via tool-registry.
4+
5+
## Features
6+
7+
- **ReAct loop** — Think → ListTools → CallTool → repeat
8+
- **Dynamic tools** — discovers available tools at runtime from tool-registry
9+
- **HITL** — pauses before destructive actions, asks user via Telegram
10+
- **Memory** — multi-turn via KAgentCheckpointer (PostgreSQL)
11+
- **Org-scoped** — only sees tools registered for its tenant+org
12+
13+
## Env vars
14+
15+
| Var | Default | Description |
16+
|-----|---------|-------------|
17+
| `TENANT_ID` | `unknown` | Tenant identifier |
18+
| `ORG_ID` | `unknown` | Organisation identifier |
19+
| `ORG_NAME` | derived | Human-readable org name |
20+
| `TOOL_REGISTRY_URL` | `http://tool-registry.platform.svc.cluster.local:8080` | Registry URL |
21+
| `REGISTRY_TOKEN` || Bearer token (from K8s Secret) |
22+
| `KAGENT_URL` | `http://kagent-controller.kagent.svc.cluster.local:8083` | For call_agent |
23+
| `OPENAI_API_KEY` || Required |
24+
| `OPENAI_MODEL` | `gpt-4o-mini` | Model name |
25+
| `OPENAI_BASE_URL` || Custom provider (e.g. BotHub) |
26+
| `DATABASE_URL` || PostgreSQL for KAgentCheckpointer |
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""auto-agent package."""
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"name": "auto-agent",
3+
"description": "Autonomous AI assistant with dynamic tool discovery. Can list and call any registered tool, ask for human confirmation before destructive actions, and delegate to other agents.",
4+
"url": "http://localhost:8080",
5+
"version": "0.9.0",
6+
"capabilities": {
7+
"streaming": true,
8+
"pushNotifications": false,
9+
"stateTransitionHistory": false
10+
},
11+
"defaultInputModes": ["text"],
12+
"defaultOutputModes": ["text"],
13+
"skills": [
14+
{
15+
"id": "autonomous-assistant",
16+
"name": "Autonomous Assistant",
17+
"description": "General-purpose autonomous assistant. Discovers and uses available tools dynamically. Asks for confirmation before destructive actions.",
18+
"tags": ["autonomous", "dynamic-tools", "hitl"]
19+
}
20+
]
21+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""Auto Agent — Phase 0.9 BYO LangGraph autonomous agent.
2+
3+
ReAct loop with dynamic tool discovery:
4+
1. think — scratch pad for planning
5+
2. list_tools — discover available tools from tool-registry
6+
3. call_tool — execute any tool by name (scope-checked via registry)
7+
4. ask_human — HITL: pause before destructive actions
8+
5. call_agent — A2A delegation to other agents
9+
10+
Memory: KAgentCheckpointer → PostgreSQL (per context_id / thread_id).
11+
"""
12+
from __future__ import annotations
13+
14+
import logging
15+
import os
16+
17+
import httpx
18+
from kagent.core import KAgentConfig
19+
from kagent.langgraph import KAgentCheckpointer
20+
from langchain_openai import ChatOpenAI
21+
from langgraph.prebuilt import create_react_agent
22+
23+
from auto_agent.tools import (
24+
ask_human,
25+
call_agent,
26+
call_tool,
27+
list_available_tools,
28+
think,
29+
)
30+
31+
log = logging.getLogger(__name__)
32+
33+
# ── Tenant identity ───────────────────────────────────────────────────────────
34+
TENANT_ID = os.getenv("TENANT_ID", "unknown")
35+
ORG_ID = os.getenv("ORG_ID", "unknown")
36+
ORG_NAME = os.getenv("ORG_NAME", ORG_ID.replace("-", " ").title())
37+
38+
# ── System prompt ─────────────────────────────────────────────────────────────
39+
SYSTEM_PROMPT = f"""You are an autonomous AI assistant for **{ORG_NAME}** \
40+
(tenant: {TENANT_ID}, org: {ORG_ID}).
41+
42+
## Workflow — always follow this order:
43+
44+
1. **think** — plan complex multi-step tasks before acting
45+
2. **list_available_tools** — discover what tools you can use
46+
3. **ask_human** — REQUIRED before ANY create / update / delete action
47+
4. **call_tool** — execute the tool with correct arguments
48+
5. On errors: retry once with corrected args, then ask_human if still failing
49+
50+
## Rules:
51+
52+
- NEVER guess tool names — always call list_available_tools first
53+
- NEVER perform destructive actions without ask_human confirmation
54+
- NEVER fabricate tool results — only report what tools actually return
55+
- Respond in the same language the user wrote in
56+
- Keep responses concise — no markdown headers in final answer
57+
58+
## Memory:
59+
60+
You remember previous messages in this conversation via thread_id.
61+
If the user refers to "earlier" or "before" — use that context.
62+
"""
63+
64+
# ── LLM ───────────────────────────────────────────────────────────────────────
65+
_model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
66+
_base_url = os.getenv("OPENAI_BASE_URL")
67+
68+
_llm_kwargs: dict = {"model": _model, "temperature": 0}
69+
if _base_url:
70+
_llm_kwargs["base_url"] = _base_url
71+
72+
log.info("LLM: model=%s base_url=%s", _model, _base_url or "(default openai)")
73+
74+
# ── KAgent memory ─────────────────────────────────────────────────────────────
75+
_kagent_config = KAgentConfig()
76+
kagent_checkpointer = KAgentCheckpointer(
77+
client=httpx.AsyncClient(base_url=_kagent_config.url),
78+
app_name=_kagent_config.app_name,
79+
)
80+
81+
# ── Graph ─────────────────────────────────────────────────────────────────────
82+
graph = create_react_agent(
83+
model=ChatOpenAI(**_llm_kwargs),
84+
tools=[think, list_available_tools, call_tool, ask_human, call_agent],
85+
checkpointer=kagent_checkpointer,
86+
prompt=SYSTEM_PROMPT,
87+
)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
"""CLI entry point for auto-agent."""
2+
from __future__ import annotations
3+
4+
import json
5+
import logging
6+
import os
7+
8+
import uvicorn
9+
from auto_agent.agent import graph
10+
from kagent.core import KAgentConfig
11+
from kagent.langgraph import KAgentApp
12+
13+
logging.basicConfig(
14+
level=logging.INFO,
15+
format="%(asctime)s %(name)s %(levelname)s %(message)s",
16+
)
17+
log = logging.getLogger(__name__)
18+
19+
AGENT_CARD_PATH = os.path.join(os.path.dirname(__file__), "agent-card.json")
20+
21+
22+
def main() -> None:
23+
with open(AGENT_CARD_PATH) as f:
24+
agent_card = json.load(f)
25+
26+
config = KAgentConfig()
27+
app = KAgentApp(graph=graph, agent_card=agent_card, config=config, tracing=False)
28+
29+
port = int(os.getenv("PORT", "8080"))
30+
host = os.getenv("HOST", "0.0.0.0")
31+
log.info(
32+
"Starting auto-agent on %s:%s tenant=%s org=%s",
33+
host, port,
34+
os.getenv("TENANT_ID", "?"),
35+
os.getenv("ORG_ID", "?"),
36+
)
37+
uvicorn.run(app.build(), host=host, port=port, log_level="info")
38+
39+
40+
if __name__ == "__main__":
41+
main()
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""HTTP client for tool-registry service.
2+
3+
GET /tools?tenant_id=X&org_id=Y → list[ToolInfo] (with TTL cache)
4+
POST /call/{name} → dict (with timeout)
5+
"""
6+
from __future__ import annotations
7+
8+
import asyncio
9+
import logging
10+
import os
11+
import time
12+
from dataclasses import dataclass, field
13+
14+
import httpx
15+
16+
log = logging.getLogger(__name__)
17+
18+
TOOL_REGISTRY_URL = os.getenv(
19+
"TOOL_REGISTRY_URL",
20+
"http://tool-registry.platform.svc.cluster.local:8080",
21+
)
22+
REGISTRY_TOKEN = os.getenv("REGISTRY_TOKEN", "")
23+
CALL_TIMEOUT = 30 # seconds
24+
_CACHE_TTL = 60 # seconds
25+
26+
27+
@dataclass
28+
class ToolInfo:
29+
name: str
30+
description: str
31+
input_schema: dict
32+
endpoint_url: str
33+
tenant_id: str | None
34+
org_id: str | None
35+
scopes: list[str] = field(default_factory=list)
36+
version: str = "v1"
37+
38+
39+
# ── in-process cache ─────────────────────────────────────────────────────────
40+
_cache: dict[str, tuple[list[ToolInfo], float]] = {} # key → (data, timestamp)
41+
_cache_lock = asyncio.Lock()
42+
43+
44+
def _cache_key(tenant_id: str, org_id: str) -> str:
45+
return f"{tenant_id}:{org_id}"
46+
47+
48+
async def get_tools(tenant_id: str, org_id: str) -> list[ToolInfo]:
49+
"""Return tools available for tenant+org. TTL-cached for 60 s."""
50+
key = _cache_key(tenant_id, org_id)
51+
52+
async with _cache_lock:
53+
cached = _cache.get(key)
54+
if cached and time.monotonic() - cached[1] < _CACHE_TTL:
55+
return cached[0]
56+
57+
headers = {"Authorization": f"Bearer {REGISTRY_TOKEN}"} if REGISTRY_TOKEN else {}
58+
try:
59+
async with httpx.AsyncClient(timeout=10, headers=headers) as client:
60+
r = await client.get(
61+
f"{TOOL_REGISTRY_URL}/tools",
62+
params={"tenant_id": tenant_id, "org_id": org_id},
63+
)
64+
r.raise_for_status()
65+
data = r.json()
66+
except Exception as exc:
67+
log.warning("registry_client.get_tools failed: %s", exc)
68+
# Return stale cache on error rather than failing the agent
69+
async with _cache_lock:
70+
stale = _cache.get(key)
71+
if stale:
72+
log.warning("Returning stale tool list for %s", key)
73+
return stale[0]
74+
return []
75+
76+
tools = [ToolInfo(**item) for item in data]
77+
async with _cache_lock:
78+
_cache[key] = (tools, time.monotonic())
79+
80+
return tools
81+
82+
83+
async def call_tool(
84+
tool_name: str,
85+
arguments: dict,
86+
tenant_id: str,
87+
org_id: str,
88+
) -> dict:
89+
"""Proxy a tool call through tool-registry with scope check + timeout."""
90+
headers = {"Authorization": f"Bearer {REGISTRY_TOKEN}"} if REGISTRY_TOKEN else {}
91+
payload = {"arguments": arguments, "org_id": org_id}
92+
93+
try:
94+
async with httpx.AsyncClient(
95+
timeout=CALL_TIMEOUT, headers=headers
96+
) as client:
97+
r = await client.post(
98+
f"{TOOL_REGISTRY_URL}/call/{tool_name}",
99+
json=payload,
100+
)
101+
if r.status_code == 403:
102+
return {"error": f"Tool '{tool_name}' not available for org '{org_id}'"}
103+
r.raise_for_status()
104+
return r.json()
105+
except httpx.TimeoutException:
106+
return {"error": f"Tool '{tool_name}' timed out after {CALL_TIMEOUT}s"}
107+
except Exception as exc:
108+
log.error("registry_client.call_tool(%s) failed: %s", tool_name, exc)
109+
return {"error": str(exc)}
110+
111+
112+
def invalidate_cache(tenant_id: str = "", org_id: str = "") -> None:
113+
"""Force cache refresh on next call. Pass empty strings to clear all."""
114+
if tenant_id or org_id:
115+
_cache.pop(_cache_key(tenant_id, org_id), None)
116+
else:
117+
_cache.clear()
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from auto_agent.tools.think import think
2+
from auto_agent.tools.list_tools import list_available_tools
3+
from auto_agent.tools.call_tool import call_tool
4+
from auto_agent.tools.ask_human import ask_human
5+
from auto_agent.tools.call_agent import call_agent
6+
7+
__all__ = [
8+
"think",
9+
"list_available_tools",
10+
"call_tool",
11+
"ask_human",
12+
"call_agent",
13+
]
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""AskHumanTool — Human-in-the-Loop via LangGraph interrupt.
2+
3+
Pauses the graph and waits for human input.
4+
Use before ANY create / update / delete action.
5+
6+
Flow:
7+
1. Agent calls ask_human(question, options)
8+
2. interrupt() fires with kagent action_requests payload
9+
3. kagent executor emits input_required + adk_request_confirmation DataPart
10+
4. User approves/rejects via TG Bot or kagent UI
11+
5. Graph resumes with the answer
12+
13+
Interrupt payload:
14+
{"action_requests": [{"name": "ask_human", "args": {question, options}, "id": "<uuid>"}]}
15+
16+
Resume value:
17+
{"decision_type": "approve"|"reject", "ask_user_answers": [{"answer": ["<text>"]}]}
18+
"""
19+
from __future__ import annotations
20+
21+
import uuid
22+
23+
from langchain_core.tools import tool
24+
from langgraph.types import interrupt
25+
26+
27+
@tool
28+
def ask_human(question: str, options: list[str] | None = None) -> str:
29+
"""Ask the user a question and wait for their answer before continuing.
30+
31+
ALWAYS call this before any action that creates, updates, or deletes data.
32+
33+
Args:
34+
question: what you are about to do and why
35+
options: suggested responses (e.g. ["Да", "Нет"]). None = free-form.
36+
37+
Returns:
38+
The human's answer, or "rejected" if the user denied.
39+
"""
40+
# kagent executor requires action_requests format to emit input_required.
41+
payload = {
42+
"action_requests": [
43+
{
44+
"name": "ask_human",
45+
"args": {"question": question, "options": options or []},
46+
"id": str(uuid.uuid4()),
47+
}
48+
]
49+
}
50+
51+
resume_value = interrupt(payload)
52+
53+
if isinstance(resume_value, dict):
54+
decision = resume_value.get("decision_type", "approve")
55+
56+
if decision == "reject":
57+
reasons = resume_value.get("rejection_reasons", {})
58+
reason = reasons.get("*", "") if isinstance(reasons, dict) else ""
59+
return f"rejected: {reason}" if reason else "rejected"
60+
61+
# approve — extract text from ask_user_answers[0]["answer"][0]
62+
answers = resume_value.get("ask_user_answers", [])
63+
if answers and isinstance(answers, list):
64+
first = answers[0]
65+
if isinstance(first, dict):
66+
answer_list = first.get("answer", [])
67+
if answer_list and isinstance(answer_list, list):
68+
return str(answer_list[0])
69+
70+
# resume_value is a plain string (e.g. in tests)
71+
return str(resume_value)

0 commit comments

Comments
 (0)