terno-agent 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.
- terno_agent/__init__.py +7 -0
- terno_agent/__main__.py +4 -0
- terno_agent/agents/__init__.py +6 -0
- terno_agent/agents/base.py +123 -0
- terno_agent/agents/coder.py +22 -0
- terno_agent/agents/database.py +28 -0
- terno_agent/agents/orchestrator.py +149 -0
- terno_agent/cli.py +323 -0
- terno_agent/config.py +110 -0
- terno_agent/core/__init__.py +52 -0
- terno_agent/core/events.py +69 -0
- terno_agent/core/exceptions.py +22 -0
- terno_agent/core/messages.py +60 -0
- terno_agent/core/tool.py +34 -0
- terno_agent/db/__init__.py +3 -0
- terno_agent/db/connection.py +117 -0
- terno_agent/llm/__init__.py +4 -0
- terno_agent/llm/anthropic_client.py +138 -0
- terno_agent/llm/base.py +46 -0
- terno_agent/llm/factory.py +23 -0
- terno_agent/llm/openai_client.py +169 -0
- terno_agent/prompts/__init__.py +5 -0
- terno_agent/prompts/coder.py +20 -0
- terno_agent/prompts/database.py +21 -0
- terno_agent/prompts/orchestrator.py +29 -0
- terno_agent/sandbox/__init__.py +4 -0
- terno_agent/sandbox/base.py +52 -0
- terno_agent/sandbox/docker.py +111 -0
- terno_agent/sandbox/factory.py +22 -0
- terno_agent/sandbox/local.py +54 -0
- terno_agent/tools/__init__.py +9 -0
- terno_agent/tools/code_exec.py +48 -0
- terno_agent/tools/sql.py +123 -0
- terno_agent-0.1.0.dist-info/METADATA +317 -0
- terno_agent-0.1.0.dist-info/RECORD +37 -0
- terno_agent-0.1.0.dist-info/WHEEL +4 -0
- terno_agent-0.1.0.dist-info/entry_points.txt +3 -0
terno_agent/__init__.py
ADDED
terno_agent/__main__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
from terno_agent.agents.base import AgentRun, BaseAgent
|
|
2
|
+
from terno_agent.agents.coder import CoderAgent
|
|
3
|
+
from terno_agent.agents.database import DatabaseAgent
|
|
4
|
+
from terno_agent.agents.orchestrator import Orchestrator
|
|
5
|
+
|
|
6
|
+
__all__ = ["AgentRun", "BaseAgent", "CoderAgent", "DatabaseAgent", "Orchestrator"]
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Agent run loop.
|
|
2
|
+
|
|
3
|
+
A `BaseAgent` ties together an LLM client, a system prompt, and a set of
|
|
4
|
+
tools. The `run` loop is the standard "think → call tools → think" cycle,
|
|
5
|
+
terminating when the model produces a final assistant message with no tool
|
|
6
|
+
calls (or when a per-agent iteration cap is reached).
|
|
7
|
+
|
|
8
|
+
The agent emits `AgentEvent`s to an optional ``on_event`` hook: streamed
|
|
9
|
+
text deltas, tool calls, tool results, and turn endings.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Iterable
|
|
16
|
+
|
|
17
|
+
from terno_agent.core.events import (
|
|
18
|
+
EventHook,
|
|
19
|
+
IterationStart,
|
|
20
|
+
TextDelta,
|
|
21
|
+
ToolCallEvent,
|
|
22
|
+
ToolResultEvent,
|
|
23
|
+
TurnEnd,
|
|
24
|
+
)
|
|
25
|
+
from terno_agent.core.exceptions import AgentError, ToolError
|
|
26
|
+
from terno_agent.core.messages import (
|
|
27
|
+
Message,
|
|
28
|
+
SystemMessage,
|
|
29
|
+
ToolCall,
|
|
30
|
+
ToolResult,
|
|
31
|
+
ToolResultMessage,
|
|
32
|
+
UserMessage,
|
|
33
|
+
)
|
|
34
|
+
from terno_agent.core.tool import Tool
|
|
35
|
+
from terno_agent.llm.base import LLMClient
|
|
36
|
+
|
|
37
|
+
Trace = list[Message]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class AgentRun:
|
|
42
|
+
answer: str
|
|
43
|
+
trace: Trace = field(default_factory=list)
|
|
44
|
+
iterations: int = 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BaseAgent:
|
|
48
|
+
name: str = "agent"
|
|
49
|
+
max_iterations: int = 12
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
llm: LLMClient,
|
|
54
|
+
system_prompt: str,
|
|
55
|
+
tools: Iterable[Tool] = (),
|
|
56
|
+
*,
|
|
57
|
+
on_event: EventHook | None = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self.llm = llm
|
|
60
|
+
self.system_prompt = system_prompt
|
|
61
|
+
self.tools: dict[str, Tool] = {t.schema.name: t for t in tools}
|
|
62
|
+
self.on_event = on_event
|
|
63
|
+
|
|
64
|
+
def run(self, task: str, *, extra_context: str | None = None) -> AgentRun:
|
|
65
|
+
system = self.system_prompt
|
|
66
|
+
if extra_context:
|
|
67
|
+
system += "\n\n---\n" + extra_context
|
|
68
|
+
|
|
69
|
+
messages: Trace = [SystemMessage(system), UserMessage(task)]
|
|
70
|
+
|
|
71
|
+
for i in range(1, self.max_iterations + 1):
|
|
72
|
+
self._emit(IterationStart(agent=self.name, iteration=i))
|
|
73
|
+
|
|
74
|
+
def _on_text(text: str) -> None:
|
|
75
|
+
self._emit(TextDelta(agent=self.name, text=text))
|
|
76
|
+
|
|
77
|
+
response = self.llm.complete(
|
|
78
|
+
messages,
|
|
79
|
+
tools=[t.schema for t in self.tools.values()],
|
|
80
|
+
on_text_delta=_on_text,
|
|
81
|
+
)
|
|
82
|
+
assistant = response.message
|
|
83
|
+
messages.append(assistant)
|
|
84
|
+
self._emit(TurnEnd(agent=self.name, message=assistant))
|
|
85
|
+
|
|
86
|
+
if not assistant.tool_calls:
|
|
87
|
+
return AgentRun(answer=assistant.content, trace=messages, iterations=i)
|
|
88
|
+
|
|
89
|
+
results: list[ToolResult] = []
|
|
90
|
+
for tc in assistant.tool_calls:
|
|
91
|
+
self._emit(ToolCallEvent(agent=self.name, call=tc))
|
|
92
|
+
result = self._run_tool_call(tc)
|
|
93
|
+
self._emit(ToolResultEvent(agent=self.name, result=result))
|
|
94
|
+
results.append(result)
|
|
95
|
+
|
|
96
|
+
messages.append(ToolResultMessage(results=results))
|
|
97
|
+
|
|
98
|
+
raise AgentError(
|
|
99
|
+
f"{self.name} exceeded max_iterations ({self.max_iterations}) without finishing."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def _run_tool_call(self, tc: ToolCall) -> ToolResult:
|
|
103
|
+
tool = self.tools.get(tc.name)
|
|
104
|
+
if tool is None:
|
|
105
|
+
return ToolResult(call_id=tc.id, content=f"Unknown tool: {tc.name}", is_error=True)
|
|
106
|
+
try:
|
|
107
|
+
output = tool.run(**tc.arguments)
|
|
108
|
+
return ToolResult(call_id=tc.id, content=output, is_error=False)
|
|
109
|
+
except ToolError as exc:
|
|
110
|
+
return ToolResult(call_id=tc.id, content=str(exc), is_error=True)
|
|
111
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
112
|
+
return ToolResult(call_id=tc.id, content=f"Unhandled tool error: {exc}", is_error=True)
|
|
113
|
+
|
|
114
|
+
def _emit(self, event) -> None:
|
|
115
|
+
if self.on_event is None:
|
|
116
|
+
return
|
|
117
|
+
try:
|
|
118
|
+
self.on_event(event)
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
__all__ = ["AgentRun", "BaseAgent", "EventHook", "Trace"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from terno_agent.agents.base import BaseAgent
|
|
4
|
+
from terno_agent.llm.base import LLMClient
|
|
5
|
+
from terno_agent.prompts.coder import CODER_PROMPT
|
|
6
|
+
from terno_agent.sandbox.base import Sandbox
|
|
7
|
+
from terno_agent.tools.code_exec import RunPythonTool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CoderAgent(BaseAgent):
|
|
11
|
+
name = "coder"
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
llm: LLMClient,
|
|
16
|
+
sandbox: Sandbox,
|
|
17
|
+
*,
|
|
18
|
+
timeout_s: int = 30,
|
|
19
|
+
on_event=None,
|
|
20
|
+
) -> None:
|
|
21
|
+
tools = [RunPythonTool(sandbox, timeout_s=timeout_s)]
|
|
22
|
+
super().__init__(llm, CODER_PROMPT, tools, on_event=on_event)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from terno_agent.agents.base import BaseAgent
|
|
4
|
+
from terno_agent.db.connection import Database
|
|
5
|
+
from terno_agent.llm.base import LLMClient
|
|
6
|
+
from terno_agent.prompts.database import DATABASE_PROMPT
|
|
7
|
+
from terno_agent.tools.sql import DescribeTableTool, ListTablesTool, SqlQueryTool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class DatabaseAgent(BaseAgent):
|
|
11
|
+
name = "database"
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self,
|
|
15
|
+
llm: LLMClient,
|
|
16
|
+
db: Database,
|
|
17
|
+
*,
|
|
18
|
+
max_rows: int = 200,
|
|
19
|
+
read_only: bool = True,
|
|
20
|
+
on_event=None,
|
|
21
|
+
) -> None:
|
|
22
|
+
tools = [
|
|
23
|
+
ListTablesTool(db),
|
|
24
|
+
DescribeTableTool(db),
|
|
25
|
+
SqlQueryTool(db, max_rows=max_rows, read_only=read_only),
|
|
26
|
+
]
|
|
27
|
+
system = DATABASE_PROMPT + "\n\nDatabase context:\n" + db.schema_overview()
|
|
28
|
+
super().__init__(llm, system, tools, on_event=on_event)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Orchestrator agent.
|
|
2
|
+
|
|
3
|
+
The orchestrator is itself a `BaseAgent` whose tools are calls to specialist
|
|
4
|
+
sub-agents. The LLM picks which specialist to invoke; we instantiate
|
|
5
|
+
specialists lazily on the first call so they can share the orchestrator's
|
|
6
|
+
LLM client.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from terno_agent.agents.base import AgentRun, BaseAgent
|
|
15
|
+
from terno_agent.agents.coder import CoderAgent
|
|
16
|
+
from terno_agent.agents.database import DatabaseAgent
|
|
17
|
+
from terno_agent.config import Config
|
|
18
|
+
from terno_agent.core.exceptions import ConfigError, ToolError
|
|
19
|
+
from terno_agent.core.tool import ToolSchema
|
|
20
|
+
from terno_agent.db.connection import Database
|
|
21
|
+
from terno_agent.llm.base import LLMClient
|
|
22
|
+
from terno_agent.llm.factory import create_llm_client
|
|
23
|
+
from terno_agent.prompts.orchestrator import ORCHESTRATOR_PROMPT
|
|
24
|
+
from terno_agent.sandbox.base import Sandbox
|
|
25
|
+
from terno_agent.sandbox.factory import create_sandbox
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class _DatabaseDelegateTool:
|
|
30
|
+
agent: DatabaseAgent
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def schema(self) -> ToolSchema:
|
|
34
|
+
return ToolSchema(
|
|
35
|
+
name="ask_database_agent",
|
|
36
|
+
description=(
|
|
37
|
+
"Delegate a task to the Database specialist. It can introspect "
|
|
38
|
+
"the schema and run read-only SQL. Returns the specialist's "
|
|
39
|
+
"natural-language answer."
|
|
40
|
+
),
|
|
41
|
+
parameters={
|
|
42
|
+
"type": "object",
|
|
43
|
+
"properties": {
|
|
44
|
+
"task": {
|
|
45
|
+
"type": "string",
|
|
46
|
+
"description": "Self-contained task or question for the DB specialist.",
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"required": ["task"],
|
|
50
|
+
},
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def run(self, **kwargs: Any) -> str:
|
|
54
|
+
task = kwargs.get("task")
|
|
55
|
+
if not task:
|
|
56
|
+
raise ToolError("ask_database_agent requires a 'task' argument.")
|
|
57
|
+
return self.agent.run(task).answer
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class _CoderDelegateTool:
|
|
62
|
+
agent: CoderAgent
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def schema(self) -> ToolSchema:
|
|
66
|
+
return ToolSchema(
|
|
67
|
+
name="ask_coder_agent",
|
|
68
|
+
description=(
|
|
69
|
+
"Delegate a task to the Coder specialist. It writes Python and "
|
|
70
|
+
"runs it in a sandbox. Pass any rows it needs to operate on "
|
|
71
|
+
"via 'input_data' as a JSON/CSV string — it cannot see prior "
|
|
72
|
+
"tool results."
|
|
73
|
+
),
|
|
74
|
+
parameters={
|
|
75
|
+
"type": "object",
|
|
76
|
+
"properties": {
|
|
77
|
+
"task": {"type": "string", "description": "What the coder should do."},
|
|
78
|
+
"input_data": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"description": "Optional data the coder should operate on (JSON/CSV/text).",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
"required": ["task"],
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def run(self, **kwargs: Any) -> str:
|
|
88
|
+
task = kwargs.get("task")
|
|
89
|
+
if not task:
|
|
90
|
+
raise ToolError("ask_coder_agent requires a 'task' argument.")
|
|
91
|
+
data = kwargs.get("input_data")
|
|
92
|
+
full = task if not data else f"{task}\n\nInput data:\n{data}"
|
|
93
|
+
return self.agent.run(full).answer
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class Orchestrator(BaseAgent):
|
|
97
|
+
name = "orchestrator"
|
|
98
|
+
max_iterations = 16
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
llm: LLMClient,
|
|
103
|
+
*,
|
|
104
|
+
database: Database,
|
|
105
|
+
sandbox: Sandbox | None,
|
|
106
|
+
on_event=None,
|
|
107
|
+
) -> None:
|
|
108
|
+
self.database = database
|
|
109
|
+
self.sandbox = sandbox
|
|
110
|
+
self._db_agent = DatabaseAgent(llm, database, on_event=on_event)
|
|
111
|
+
tools = [_DatabaseDelegateTool(self._db_agent)]
|
|
112
|
+
if sandbox is not None:
|
|
113
|
+
self._coder_agent = CoderAgent(llm, sandbox, on_event=on_event)
|
|
114
|
+
tools.append(_CoderDelegateTool(self._coder_agent))
|
|
115
|
+
else:
|
|
116
|
+
self._coder_agent = None
|
|
117
|
+
|
|
118
|
+
system = ORCHESTRATOR_PROMPT
|
|
119
|
+
if sandbox is None:
|
|
120
|
+
system += "\n\nNote: the coder agent is disabled in this session — answer using SQL only."
|
|
121
|
+
super().__init__(llm, system, tools, on_event=on_event)
|
|
122
|
+
|
|
123
|
+
# ----- Construction helpers -------------------------------------------------
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def from_env(cls, on_event=None) -> "Orchestrator":
|
|
127
|
+
return cls.from_config(Config.from_env(), on_event=on_event)
|
|
128
|
+
|
|
129
|
+
@classmethod
|
|
130
|
+
def from_config(cls, config: Config, *, on_event=None) -> "Orchestrator":
|
|
131
|
+
if not config.database_url:
|
|
132
|
+
raise ConfigError("TERNO_DATABASE_URL is required.")
|
|
133
|
+
llm = create_llm_client(
|
|
134
|
+
provider=config.llm_provider,
|
|
135
|
+
model=config.llm_model,
|
|
136
|
+
api_key=config.llm_api_key,
|
|
137
|
+
)
|
|
138
|
+
database = Database(config.database_url)
|
|
139
|
+
sandbox: Sandbox | None
|
|
140
|
+
if config.sandbox == "none":
|
|
141
|
+
sandbox = None
|
|
142
|
+
else:
|
|
143
|
+
sandbox = create_sandbox(config.sandbox)
|
|
144
|
+
return cls(llm, database=database, sandbox=sandbox, on_event=on_event)
|
|
145
|
+
|
|
146
|
+
# ----- Convenience ---------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def ask(self, question: str) -> AgentRun:
|
|
149
|
+
return self.run(question)
|
terno_agent/cli.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""argparse-based CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, distribution
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.syntax import Syntax
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from terno_agent import __version__
|
|
17
|
+
from terno_agent.agents.orchestrator import Orchestrator
|
|
18
|
+
from terno_agent.config import Config
|
|
19
|
+
from terno_agent.core.events import (
|
|
20
|
+
AgentEvent,
|
|
21
|
+
IterationStart,
|
|
22
|
+
TextDelta,
|
|
23
|
+
ToolCallEvent,
|
|
24
|
+
ToolResultEvent,
|
|
25
|
+
TurnEnd,
|
|
26
|
+
)
|
|
27
|
+
from terno_agent.core.exceptions import TernoError
|
|
28
|
+
|
|
29
|
+
_PACKAGE_NAME = "terno-agent"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_editable_install() -> bool:
|
|
33
|
+
"""Return True for development installs (editable or run-from-source).
|
|
34
|
+
|
|
35
|
+
Detection follows PEP 610: a non-editable install from a wheel/PyPI either
|
|
36
|
+
has no ``direct_url.json`` or has ``dir_info.editable = false``. An
|
|
37
|
+
editable install always sets ``dir_info.editable = true``. Running from
|
|
38
|
+
source without ``pip install`` will raise ``PackageNotFoundError``, which
|
|
39
|
+
we also treat as development mode.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
dist = distribution(_PACKAGE_NAME)
|
|
43
|
+
except PackageNotFoundError:
|
|
44
|
+
return True
|
|
45
|
+
raw = dist.read_text("direct_url.json")
|
|
46
|
+
if not raw:
|
|
47
|
+
return False
|
|
48
|
+
try:
|
|
49
|
+
info = json.loads(raw)
|
|
50
|
+
except json.JSONDecodeError:
|
|
51
|
+
return False
|
|
52
|
+
return bool((info.get("dir_info") or {}).get("editable"))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_AGENT_COLORS = {
|
|
56
|
+
"orchestrator": "bold magenta",
|
|
57
|
+
"database": "bold cyan",
|
|
58
|
+
"coder": "bold green",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
63
|
+
parser = _build_parser()
|
|
64
|
+
args = parser.parse_args(argv)
|
|
65
|
+
|
|
66
|
+
if args.command is None:
|
|
67
|
+
parser.print_help()
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
return args.func(args)
|
|
72
|
+
except TernoError as exc:
|
|
73
|
+
Console(stderr=True).print(f"[bold red]error:[/] {exc}")
|
|
74
|
+
return 2
|
|
75
|
+
except KeyboardInterrupt:
|
|
76
|
+
print("\ninterrupted", file=sys.stderr)
|
|
77
|
+
return 130
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
81
|
+
p = argparse.ArgumentParser(
|
|
82
|
+
prog="terno",
|
|
83
|
+
description="Multi-agent CLI that answers questions about your database.",
|
|
84
|
+
)
|
|
85
|
+
p.add_argument("--version", action="version", version=f"terno-agent {__version__}")
|
|
86
|
+
p.add_argument(
|
|
87
|
+
"-q",
|
|
88
|
+
"--quiet",
|
|
89
|
+
action="store_true",
|
|
90
|
+
help="Suppress agent activity; print only the final answer.",
|
|
91
|
+
)
|
|
92
|
+
sub = p.add_subparsers(dest="command")
|
|
93
|
+
|
|
94
|
+
ask = sub.add_parser("ask", help="Ask a single question and exit.")
|
|
95
|
+
ask.add_argument("question", nargs="+", help="The question to ask.")
|
|
96
|
+
ask.set_defaults(func=_cmd_ask)
|
|
97
|
+
|
|
98
|
+
chat = sub.add_parser("chat", help="Start an interactive REPL.")
|
|
99
|
+
chat.set_defaults(func=_cmd_chat)
|
|
100
|
+
|
|
101
|
+
cfg = sub.add_parser("config", help="Show effective configuration.")
|
|
102
|
+
cfg.set_defaults(func=_cmd_config)
|
|
103
|
+
|
|
104
|
+
return p
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _cmd_ask(args: argparse.Namespace) -> int:
|
|
108
|
+
question = " ".join(args.question)
|
|
109
|
+
console = Console()
|
|
110
|
+
renderer = None if args.quiet else AgentRenderer(console)
|
|
111
|
+
agent = Orchestrator.from_env(on_event=renderer)
|
|
112
|
+
result = agent.ask(question)
|
|
113
|
+
if renderer is not None:
|
|
114
|
+
renderer.finalize()
|
|
115
|
+
if args.quiet:
|
|
116
|
+
print(result.answer)
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _cmd_chat(args: argparse.Namespace) -> int:
|
|
121
|
+
console = Console()
|
|
122
|
+
if not _is_editable_install():
|
|
123
|
+
Console(stderr=True).print(
|
|
124
|
+
"[bold red]error:[/] `terno chat` is only available in a development install.\n"
|
|
125
|
+
"Install from source to use the REPL:\n"
|
|
126
|
+
" [bold]pip install -e .[/] or [bold]uv tool install --editable .[/]\n"
|
|
127
|
+
"For published installs, use [bold]terno ask \"...\"[/] instead."
|
|
128
|
+
)
|
|
129
|
+
return 2
|
|
130
|
+
renderer = None if args.quiet else AgentRenderer(console)
|
|
131
|
+
agent = Orchestrator.from_env(on_event=renderer)
|
|
132
|
+
console.print("[bold]terno-agent REPL[/] — type 'exit' or Ctrl-D to quit.\n")
|
|
133
|
+
while True:
|
|
134
|
+
try:
|
|
135
|
+
line = input("you> ").strip()
|
|
136
|
+
except EOFError:
|
|
137
|
+
print()
|
|
138
|
+
return 0
|
|
139
|
+
if not line:
|
|
140
|
+
continue
|
|
141
|
+
if line.lower() in {"exit", "quit", ":q"}:
|
|
142
|
+
return 0
|
|
143
|
+
try:
|
|
144
|
+
result = agent.ask(line)
|
|
145
|
+
except TernoError as exc:
|
|
146
|
+
console.print(f"[bold red]error:[/] {exc}")
|
|
147
|
+
continue
|
|
148
|
+
if renderer is not None:
|
|
149
|
+
renderer.finalize()
|
|
150
|
+
renderer.reset()
|
|
151
|
+
if args.quiet:
|
|
152
|
+
print(f"terno> {result.answer}\n")
|
|
153
|
+
else:
|
|
154
|
+
console.print()
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _cmd_config(_args: argparse.Namespace) -> int:
|
|
159
|
+
print(Config.from_env().display())
|
|
160
|
+
return 0
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# --------------------------------------------------------------------------- #
|
|
164
|
+
# Event renderer
|
|
165
|
+
# --------------------------------------------------------------------------- #
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class AgentRenderer:
|
|
169
|
+
"""Render `AgentEvent`s to a rich Console.
|
|
170
|
+
|
|
171
|
+
Streams assistant text inline; surrounds tool calls and results with
|
|
172
|
+
syntax-highlighted panels. Designed to be called from any thread.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
def __init__(self, console: Console) -> None:
|
|
176
|
+
self.console = console
|
|
177
|
+
self._stream_open = False
|
|
178
|
+
self._current_agent: str | None = None
|
|
179
|
+
|
|
180
|
+
# callable as the event hook
|
|
181
|
+
def __call__(self, event: AgentEvent) -> None:
|
|
182
|
+
if isinstance(event, IterationStart):
|
|
183
|
+
return
|
|
184
|
+
if isinstance(event, TextDelta):
|
|
185
|
+
self._handle_text(event)
|
|
186
|
+
elif isinstance(event, ToolCallEvent):
|
|
187
|
+
self._close_stream()
|
|
188
|
+
self._render_tool_call(event)
|
|
189
|
+
elif isinstance(event, ToolResultEvent):
|
|
190
|
+
self._close_stream()
|
|
191
|
+
self._render_tool_result(event)
|
|
192
|
+
elif isinstance(event, TurnEnd):
|
|
193
|
+
self._close_stream()
|
|
194
|
+
|
|
195
|
+
def finalize(self) -> None:
|
|
196
|
+
self._close_stream()
|
|
197
|
+
|
|
198
|
+
def reset(self) -> None:
|
|
199
|
+
self._current_agent = None
|
|
200
|
+
|
|
201
|
+
# ----- streaming text ------------------------------------------------- #
|
|
202
|
+
|
|
203
|
+
def _handle_text(self, event: TextDelta) -> None:
|
|
204
|
+
if not event.text:
|
|
205
|
+
return
|
|
206
|
+
if not self._stream_open or self._current_agent != event.agent:
|
|
207
|
+
self._close_stream()
|
|
208
|
+
tag = self._agent_tag(event.agent)
|
|
209
|
+
self.console.print(tag, end=" ", highlight=False)
|
|
210
|
+
self._stream_open = True
|
|
211
|
+
self._current_agent = event.agent
|
|
212
|
+
self.console.print(event.text, end="", highlight=False, markup=False)
|
|
213
|
+
|
|
214
|
+
def _close_stream(self) -> None:
|
|
215
|
+
if self._stream_open:
|
|
216
|
+
self.console.print() # newline to end the streaming line
|
|
217
|
+
self._stream_open = False
|
|
218
|
+
|
|
219
|
+
def _agent_tag(self, agent: str) -> Text:
|
|
220
|
+
style = _AGENT_COLORS.get(agent, "bold white")
|
|
221
|
+
return Text(f"[{agent}]", style=style)
|
|
222
|
+
|
|
223
|
+
# ----- tool call/result panels --------------------------------------- #
|
|
224
|
+
|
|
225
|
+
def _render_tool_call(self, event: ToolCallEvent) -> None:
|
|
226
|
+
call = event.call
|
|
227
|
+
body = _format_call_body(call.name, call.arguments)
|
|
228
|
+
title = f"[{event.agent}] → {call.name}"
|
|
229
|
+
self.console.print(
|
|
230
|
+
Panel(
|
|
231
|
+
body,
|
|
232
|
+
title=title,
|
|
233
|
+
title_align="left",
|
|
234
|
+
border_style=_AGENT_COLORS.get(event.agent, "white"),
|
|
235
|
+
padding=(0, 1),
|
|
236
|
+
)
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def _render_tool_result(self, event: ToolResultEvent) -> None:
|
|
240
|
+
result = event.result
|
|
241
|
+
body = _format_result_body(result.content)
|
|
242
|
+
style = "red" if result.is_error else "dim"
|
|
243
|
+
marker = "✗" if result.is_error else "✓"
|
|
244
|
+
title = f"[{event.agent}] {marker} result"
|
|
245
|
+
self.console.print(
|
|
246
|
+
Panel(body, title=title, title_align="left", border_style=style, padding=(0, 1))
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# --------------------------------------------------------------------------- #
|
|
251
|
+
# Tool-call / result formatting helpers
|
|
252
|
+
# --------------------------------------------------------------------------- #
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _format_call_body(name: str, args: dict) -> object:
|
|
256
|
+
"""Pick the best rich renderable for the tool's payload."""
|
|
257
|
+
if name == "sql_query" and isinstance(args.get("sql"), str):
|
|
258
|
+
return Syntax(args["sql"].strip(), "sql", theme="ansi_dark", word_wrap=True)
|
|
259
|
+
if name == "run_python" and isinstance(args.get("code"), str):
|
|
260
|
+
return Syntax(args["code"], "python", theme="ansi_dark", word_wrap=True)
|
|
261
|
+
if name in {"ask_database_agent", "ask_coder_agent"}:
|
|
262
|
+
task = args.get("task", "")
|
|
263
|
+
extra = args.get("input_data")
|
|
264
|
+
body = Text(task, no_wrap=False)
|
|
265
|
+
if extra:
|
|
266
|
+
body.append("\n\ninput_data:\n", style="dim")
|
|
267
|
+
body.append(_truncate(extra, 800))
|
|
268
|
+
return body
|
|
269
|
+
pretty = json.dumps(args, indent=2, default=str)
|
|
270
|
+
return Syntax(pretty, "json", theme="ansi_dark", word_wrap=True)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _format_result_body(content: str) -> object:
|
|
274
|
+
text = content.strip()
|
|
275
|
+
if not text:
|
|
276
|
+
return Text("(empty)", style="dim italic")
|
|
277
|
+
try:
|
|
278
|
+
parsed = json.loads(text)
|
|
279
|
+
except (json.JSONDecodeError, ValueError):
|
|
280
|
+
return Text(_truncate(text, 4000))
|
|
281
|
+
if isinstance(parsed, dict) and {"columns", "rows"} <= parsed.keys():
|
|
282
|
+
return _format_query_result(parsed)
|
|
283
|
+
pretty = json.dumps(parsed, indent=2, default=str)
|
|
284
|
+
return Syntax(_truncate(pretty, 4000), "json", theme="ansi_dark", word_wrap=True)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _format_query_result(payload: dict) -> object:
|
|
288
|
+
from rich.table import Table
|
|
289
|
+
|
|
290
|
+
columns: list[str] = payload.get("columns") or []
|
|
291
|
+
rows: list[list] = payload.get("rows") or []
|
|
292
|
+
table = Table(show_header=True, header_style="bold cyan", expand=False)
|
|
293
|
+
for col in columns:
|
|
294
|
+
table.add_column(str(col))
|
|
295
|
+
preview = rows[:20]
|
|
296
|
+
for row in preview:
|
|
297
|
+
table.add_row(*[_cell(v) for v in row])
|
|
298
|
+
footer = f"{payload.get('row_count', len(rows))} rows"
|
|
299
|
+
if payload.get("truncated"):
|
|
300
|
+
footer += " (truncated)"
|
|
301
|
+
if len(rows) > len(preview):
|
|
302
|
+
footer += f" — showing first {len(preview)}"
|
|
303
|
+
table.caption = footer
|
|
304
|
+
return table
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _cell(value: object) -> str:
|
|
308
|
+
if value is None:
|
|
309
|
+
return "—"
|
|
310
|
+
text = str(value)
|
|
311
|
+
if len(text) > 80:
|
|
312
|
+
text = text[:77] + "..."
|
|
313
|
+
return text
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _truncate(text: str, limit: int) -> str:
|
|
317
|
+
if len(text) <= limit:
|
|
318
|
+
return text
|
|
319
|
+
return text[:limit] + f"\n... [truncated {len(text) - limit} chars]"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__":
|
|
323
|
+
raise SystemExit(main())
|