glaip-sdk 0.0.1b5__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.
- glaip_sdk/__init__.py +12 -0
- glaip_sdk/cli/__init__.py +9 -0
- glaip_sdk/cli/commands/__init__.py +5 -0
- glaip_sdk/cli/commands/agents.py +415 -0
- glaip_sdk/cli/commands/configure.py +316 -0
- glaip_sdk/cli/commands/init.py +168 -0
- glaip_sdk/cli/commands/mcps.py +473 -0
- glaip_sdk/cli/commands/models.py +52 -0
- glaip_sdk/cli/commands/tools.py +309 -0
- glaip_sdk/cli/config.py +592 -0
- glaip_sdk/cli/main.py +298 -0
- glaip_sdk/cli/utils.py +733 -0
- glaip_sdk/client/__init__.py +179 -0
- glaip_sdk/client/agents.py +441 -0
- glaip_sdk/client/base.py +223 -0
- glaip_sdk/client/mcps.py +94 -0
- glaip_sdk/client/tools.py +193 -0
- glaip_sdk/client/validators.py +166 -0
- glaip_sdk/config/constants.py +28 -0
- glaip_sdk/exceptions.py +93 -0
- glaip_sdk/models.py +190 -0
- glaip_sdk/utils/__init__.py +95 -0
- glaip_sdk/utils/run_renderer.py +1009 -0
- glaip_sdk/utils.py +167 -0
- glaip_sdk-0.0.1b5.dist-info/METADATA +633 -0
- glaip_sdk-0.0.1b5.dist-info/RECORD +28 -0
- glaip_sdk-0.0.1b5.dist-info/WHEEL +4 -0
- glaip_sdk-0.0.1b5.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Main client for AIP SDK.
|
|
3
|
+
|
|
4
|
+
Authors:
|
|
5
|
+
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from glaip_sdk.client.agents import AgentClient
|
|
9
|
+
from glaip_sdk.client.base import BaseClient
|
|
10
|
+
from glaip_sdk.client.mcps import MCPClient
|
|
11
|
+
from glaip_sdk.client.tools import ToolClient
|
|
12
|
+
from glaip_sdk.models import MCP, Agent, Tool
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Client(BaseClient):
|
|
16
|
+
"""Main client that composes all specialized clients and shares one HTTP session."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, **kwargs):
|
|
19
|
+
super().__init__(**kwargs)
|
|
20
|
+
# Share the single httpx.Client + config with sub-clients
|
|
21
|
+
shared_config = {
|
|
22
|
+
"parent_client": self,
|
|
23
|
+
"api_url": self.api_url,
|
|
24
|
+
"api_key": self.api_key,
|
|
25
|
+
"timeout": self._timeout,
|
|
26
|
+
}
|
|
27
|
+
self.agents = AgentClient(**shared_config)
|
|
28
|
+
self.tools = ToolClient(**shared_config)
|
|
29
|
+
self.mcps = MCPClient(**shared_config)
|
|
30
|
+
|
|
31
|
+
# ---- Agents
|
|
32
|
+
def list_agents(self) -> list[Agent]:
|
|
33
|
+
agents = self.agents.list_agents()
|
|
34
|
+
for agent in agents:
|
|
35
|
+
agent._set_client(self)
|
|
36
|
+
return agents
|
|
37
|
+
|
|
38
|
+
def get_agent_by_id(self, agent_id: str) -> Agent:
|
|
39
|
+
agent = self.agents.get_agent_by_id(agent_id)
|
|
40
|
+
agent._set_client(self)
|
|
41
|
+
return agent
|
|
42
|
+
|
|
43
|
+
def find_agents(self, name: str | None = None) -> list[Agent]:
|
|
44
|
+
agents = self.agents.find_agents(name)
|
|
45
|
+
for agent in agents:
|
|
46
|
+
agent._set_client(self)
|
|
47
|
+
return agents
|
|
48
|
+
|
|
49
|
+
def create_agent(
|
|
50
|
+
self,
|
|
51
|
+
name: str | None = None,
|
|
52
|
+
model: str | None = None,
|
|
53
|
+
instruction: str | None = None,
|
|
54
|
+
tools: list[str | Tool] | None = None,
|
|
55
|
+
agents: list[str | Agent] | None = None,
|
|
56
|
+
timeout: int = 300,
|
|
57
|
+
**kwargs,
|
|
58
|
+
) -> Agent:
|
|
59
|
+
agent = self.agents.create_agent(
|
|
60
|
+
name=name,
|
|
61
|
+
model=model,
|
|
62
|
+
instruction=instruction,
|
|
63
|
+
tools=tools,
|
|
64
|
+
agents=agents,
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
**kwargs,
|
|
67
|
+
)
|
|
68
|
+
agent._set_client(self)
|
|
69
|
+
return agent
|
|
70
|
+
|
|
71
|
+
def delete_agent(self, agent_id: str) -> None:
|
|
72
|
+
"""Delete an agent by ID."""
|
|
73
|
+
return self.agents.delete_agent(agent_id)
|
|
74
|
+
|
|
75
|
+
def update_agent(
|
|
76
|
+
self, agent_id: str, update_data: dict | None = None, **kwargs
|
|
77
|
+
) -> Agent:
|
|
78
|
+
"""Update an agent by ID."""
|
|
79
|
+
if update_data:
|
|
80
|
+
kwargs.update(update_data)
|
|
81
|
+
return self.agents.update_agent(agent_id, **kwargs)
|
|
82
|
+
|
|
83
|
+
# ---- Tools
|
|
84
|
+
def list_tools(self) -> list[Tool]:
|
|
85
|
+
tools = self.tools.list_tools()
|
|
86
|
+
for tool in tools:
|
|
87
|
+
tool._set_client(self)
|
|
88
|
+
return tools
|
|
89
|
+
|
|
90
|
+
def get_tool_by_id(self, tool_id: str) -> Tool:
|
|
91
|
+
tool = self.tools.get_tool_by_id(tool_id)
|
|
92
|
+
tool._set_client(self)
|
|
93
|
+
return tool
|
|
94
|
+
|
|
95
|
+
def find_tools(self, name: str | None = None) -> list[Tool]:
|
|
96
|
+
tools = self.tools.find_tools(name)
|
|
97
|
+
for tool in tools:
|
|
98
|
+
tool._set_client(self)
|
|
99
|
+
return tools
|
|
100
|
+
|
|
101
|
+
def create_tool(self, **kwargs) -> Tool:
|
|
102
|
+
tool = self.tools.create_tool(**kwargs)
|
|
103
|
+
tool._set_client(self)
|
|
104
|
+
return tool
|
|
105
|
+
|
|
106
|
+
def create_tool_from_code(
|
|
107
|
+
self, name: str, code: str, framework: str = "langchain"
|
|
108
|
+
) -> Tool:
|
|
109
|
+
"""Create a new tool plugin from code string."""
|
|
110
|
+
tool = self.tools.create_tool_from_code(name, code, framework)
|
|
111
|
+
tool._set_client(self)
|
|
112
|
+
return tool
|
|
113
|
+
|
|
114
|
+
def update_tool(self, tool_id: str, **kwargs) -> Tool:
|
|
115
|
+
"""Update an existing tool."""
|
|
116
|
+
return self.tools.update_tool(tool_id, **kwargs)
|
|
117
|
+
|
|
118
|
+
def delete_tool(self, tool_id: str) -> None:
|
|
119
|
+
"""Delete a tool by ID."""
|
|
120
|
+
return self.tools.delete_tool(tool_id)
|
|
121
|
+
|
|
122
|
+
# ---- MCPs
|
|
123
|
+
def list_mcps(self) -> list[MCP]:
|
|
124
|
+
mcps = self.mcps.list_mcps()
|
|
125
|
+
for mcp in mcps:
|
|
126
|
+
mcp._set_client(self)
|
|
127
|
+
return mcps
|
|
128
|
+
|
|
129
|
+
def get_mcp_by_id(self, mcp_id: str) -> MCP:
|
|
130
|
+
mcp = self.mcps.get_mcp_by_id(mcp_id)
|
|
131
|
+
mcp._set_client(self)
|
|
132
|
+
return mcp
|
|
133
|
+
|
|
134
|
+
def find_mcps(self, name: str | None = None) -> list[MCP]:
|
|
135
|
+
mcps = self.mcps.find_mcps(name)
|
|
136
|
+
for mcp in mcps:
|
|
137
|
+
mcp._set_client(self)
|
|
138
|
+
return mcps
|
|
139
|
+
|
|
140
|
+
def create_mcp(self, **kwargs) -> MCP:
|
|
141
|
+
mcp = self.mcps.create_mcp(**kwargs)
|
|
142
|
+
mcp._set_client(self)
|
|
143
|
+
return mcp
|
|
144
|
+
|
|
145
|
+
def delete_mcp(self, mcp_id: str) -> None:
|
|
146
|
+
"""Delete an MCP by ID."""
|
|
147
|
+
return self.mcps.delete_mcp(mcp_id)
|
|
148
|
+
|
|
149
|
+
def update_mcp(self, mcp_id: str, **kwargs) -> MCP:
|
|
150
|
+
"""Update an MCP by ID."""
|
|
151
|
+
return self.mcps.update_mcp(mcp_id, **kwargs)
|
|
152
|
+
|
|
153
|
+
def run_agent(self, agent_id: str, message: str, **kwargs) -> str:
|
|
154
|
+
"""Run an agent with a message."""
|
|
155
|
+
return self.agents.run_agent(agent_id, message, **kwargs)
|
|
156
|
+
|
|
157
|
+
# ---- Language Models
|
|
158
|
+
def list_language_models(self) -> list[dict]:
|
|
159
|
+
"""List available language models."""
|
|
160
|
+
data = self._request("GET", "/language-models")
|
|
161
|
+
return data or []
|
|
162
|
+
|
|
163
|
+
# ---- Aliases (back-compat)
|
|
164
|
+
def get_agent(self, agent_id: str) -> Agent:
|
|
165
|
+
return self.get_agent_by_id(agent_id)
|
|
166
|
+
|
|
167
|
+
def get_tool(self, tool_id: str) -> Tool:
|
|
168
|
+
return self.get_tool_by_id(tool_id)
|
|
169
|
+
|
|
170
|
+
def get_mcp(self, mcp_id: str) -> MCP:
|
|
171
|
+
return self.get_mcp_by_id(mcp_id)
|
|
172
|
+
|
|
173
|
+
# ---- Health
|
|
174
|
+
def ping(self) -> bool:
|
|
175
|
+
try:
|
|
176
|
+
self._request("GET", "/health-check")
|
|
177
|
+
return True
|
|
178
|
+
except Exception:
|
|
179
|
+
return False
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Agent client for AIP SDK.
|
|
3
|
+
|
|
4
|
+
Authors:
|
|
5
|
+
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any, BinaryIO
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from glaip_sdk.client.base import BaseClient
|
|
17
|
+
from glaip_sdk.models import Agent
|
|
18
|
+
from glaip_sdk.utils.run_renderer import (
|
|
19
|
+
RichStreamRenderer,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Set up module-level logger
|
|
23
|
+
logger = logging.getLogger("glaip_sdk.agents")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _select_renderer(
|
|
27
|
+
renderer: RichStreamRenderer | str | None, *, verbose: bool = False
|
|
28
|
+
) -> RichStreamRenderer:
|
|
29
|
+
"""Select the appropriate renderer based on input."""
|
|
30
|
+
if isinstance(renderer, RichStreamRenderer):
|
|
31
|
+
return renderer
|
|
32
|
+
|
|
33
|
+
console = Console(file=sys.stdout, force_terminal=sys.stdout.isatty())
|
|
34
|
+
|
|
35
|
+
if renderer in (None, "auto"):
|
|
36
|
+
return RichStreamRenderer(console=console, verbose=verbose)
|
|
37
|
+
if renderer == "json":
|
|
38
|
+
# JSON output is handled by the renderer itself
|
|
39
|
+
return RichStreamRenderer(console=console, verbose=verbose)
|
|
40
|
+
if renderer == "markdown":
|
|
41
|
+
# Markdown output is handled by the renderer itself
|
|
42
|
+
return RichStreamRenderer(console=console, verbose=verbose)
|
|
43
|
+
if renderer == "plain":
|
|
44
|
+
# Plain output is handled by the renderer itself
|
|
45
|
+
return RichStreamRenderer(console=console, verbose=verbose)
|
|
46
|
+
|
|
47
|
+
raise ValueError(f"Unknown renderer: {renderer}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AgentClient(BaseClient):
|
|
51
|
+
"""Client for agent operations."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, *, parent_client: BaseClient | None = None, **kwargs):
|
|
54
|
+
"""Initialize the agent client.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
parent_client: Parent client to adopt session/config from
|
|
58
|
+
**kwargs: Additional arguments for standalone initialization
|
|
59
|
+
"""
|
|
60
|
+
super().__init__(parent_client=parent_client, **kwargs)
|
|
61
|
+
|
|
62
|
+
def _extract_ids(self, items: list[str | Any] | None) -> list[str] | None:
|
|
63
|
+
"""Extract IDs from a list of objects or strings."""
|
|
64
|
+
if not items:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
ids = []
|
|
68
|
+
for item in items:
|
|
69
|
+
if isinstance(item, str):
|
|
70
|
+
ids.append(item)
|
|
71
|
+
elif hasattr(item, "id"):
|
|
72
|
+
ids.append(item.id)
|
|
73
|
+
else:
|
|
74
|
+
# Fallback: convert to string
|
|
75
|
+
ids.append(str(item))
|
|
76
|
+
|
|
77
|
+
return ids
|
|
78
|
+
|
|
79
|
+
def list_agents(self) -> list[Agent]:
|
|
80
|
+
"""List all agents."""
|
|
81
|
+
data = self._request("GET", "/agents/")
|
|
82
|
+
return [Agent(**agent_data)._set_client(self) for agent_data in (data or [])]
|
|
83
|
+
|
|
84
|
+
def get_agent_by_id(self, agent_id: str) -> Agent:
|
|
85
|
+
"""Get agent by ID."""
|
|
86
|
+
data = self._request("GET", f"/agents/{agent_id}")
|
|
87
|
+
return Agent(**data)._set_client(self)
|
|
88
|
+
|
|
89
|
+
def find_agents(self, name: str | None = None) -> list[Agent]:
|
|
90
|
+
"""Find agents by name."""
|
|
91
|
+
params = {}
|
|
92
|
+
if name:
|
|
93
|
+
params["name"] = name
|
|
94
|
+
|
|
95
|
+
data = self._request("GET", "/agents/", params=params)
|
|
96
|
+
return [Agent(**agent_data)._set_client(self) for agent_data in (data or [])]
|
|
97
|
+
|
|
98
|
+
def create_agent(
|
|
99
|
+
self,
|
|
100
|
+
name: str,
|
|
101
|
+
instruction: str,
|
|
102
|
+
model: str = "gpt-4.1",
|
|
103
|
+
tools: list[str | Any] | None = None,
|
|
104
|
+
agents: list[str | Any] | None = None,
|
|
105
|
+
timeout: int = 300,
|
|
106
|
+
**kwargs,
|
|
107
|
+
) -> "Agent":
|
|
108
|
+
"""Create a new agent."""
|
|
109
|
+
# Client-side validation
|
|
110
|
+
if not name or not name.strip():
|
|
111
|
+
raise ValueError("Agent name cannot be empty or whitespace")
|
|
112
|
+
|
|
113
|
+
if not instruction or not instruction.strip():
|
|
114
|
+
raise ValueError("Agent instruction cannot be empty or whitespace")
|
|
115
|
+
|
|
116
|
+
if len(instruction.strip()) < 10:
|
|
117
|
+
raise ValueError("Agent instruction must be at least 10 characters long")
|
|
118
|
+
|
|
119
|
+
# Prepare the creation payload
|
|
120
|
+
payload = {
|
|
121
|
+
"name": name.strip(),
|
|
122
|
+
"instruction": instruction.strip(),
|
|
123
|
+
"type": "config",
|
|
124
|
+
"framework": "langchain",
|
|
125
|
+
"version": "1.0",
|
|
126
|
+
"provider": "openai",
|
|
127
|
+
"model_name": model or "gpt-4.1", # Ensure model_name is never None
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# Extract IDs from tool and agent objects
|
|
131
|
+
tool_ids = self._extract_ids(tools)
|
|
132
|
+
agent_ids = self._extract_ids(agents)
|
|
133
|
+
|
|
134
|
+
# Add tools and agents if provided
|
|
135
|
+
if tool_ids:
|
|
136
|
+
payload["tools"] = tool_ids
|
|
137
|
+
if agent_ids:
|
|
138
|
+
payload["agents"] = agent_ids
|
|
139
|
+
|
|
140
|
+
# Add any additional kwargs
|
|
141
|
+
payload.update(kwargs)
|
|
142
|
+
|
|
143
|
+
# Create the agent
|
|
144
|
+
data = self._request("POST", "/agents/", json=payload)
|
|
145
|
+
|
|
146
|
+
# The backend only returns the ID, so we need to fetch the full agent details
|
|
147
|
+
agent_id = data.get("id")
|
|
148
|
+
if not agent_id:
|
|
149
|
+
raise ValueError("Backend did not return agent ID")
|
|
150
|
+
|
|
151
|
+
# Fetch the full agent details
|
|
152
|
+
full_agent_data = self._request("GET", f"/agents/{agent_id}")
|
|
153
|
+
return Agent(**full_agent_data)._set_client(self)
|
|
154
|
+
|
|
155
|
+
def update_agent(
|
|
156
|
+
self,
|
|
157
|
+
agent_id: str,
|
|
158
|
+
name: str | None = None,
|
|
159
|
+
instruction: str | None = None,
|
|
160
|
+
model: str | None = None,
|
|
161
|
+
**kwargs,
|
|
162
|
+
) -> "Agent":
|
|
163
|
+
"""Update an existing agent."""
|
|
164
|
+
# First, get the current agent data
|
|
165
|
+
current_agent = self.get_agent_by_id(agent_id)
|
|
166
|
+
|
|
167
|
+
# Prepare the update payload with current values as defaults
|
|
168
|
+
update_data = {
|
|
169
|
+
"name": name if name is not None else current_agent.name,
|
|
170
|
+
"instruction": instruction
|
|
171
|
+
if instruction is not None
|
|
172
|
+
else current_agent.instruction,
|
|
173
|
+
"type": "config", # Required by backend
|
|
174
|
+
"framework": "langchain", # Required by backend
|
|
175
|
+
"version": "1.0", # Required by backend
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
# Handle model specification
|
|
179
|
+
if model is not None:
|
|
180
|
+
update_data["provider"] = "openai" # Default provider
|
|
181
|
+
update_data["model_name"] = model
|
|
182
|
+
else:
|
|
183
|
+
# Use current model if available
|
|
184
|
+
if hasattr(current_agent, "agent_config") and current_agent.agent_config:
|
|
185
|
+
if "lm_provider" in current_agent.agent_config:
|
|
186
|
+
update_data["provider"] = current_agent.agent_config["lm_provider"]
|
|
187
|
+
if "lm_name" in current_agent.agent_config:
|
|
188
|
+
update_data["model_name"] = current_agent.agent_config["lm_name"]
|
|
189
|
+
else:
|
|
190
|
+
# Default values
|
|
191
|
+
update_data["provider"] = "openai"
|
|
192
|
+
update_data["model_name"] = "gpt-4.1"
|
|
193
|
+
|
|
194
|
+
# Handle tools and agents
|
|
195
|
+
if "tools" in kwargs:
|
|
196
|
+
tool_ids = self._extract_ids(kwargs["tools"])
|
|
197
|
+
if tool_ids:
|
|
198
|
+
update_data["tools"] = tool_ids
|
|
199
|
+
elif current_agent.tools:
|
|
200
|
+
update_data["tools"] = [
|
|
201
|
+
tool["id"] if isinstance(tool, dict) else tool
|
|
202
|
+
for tool in current_agent.tools
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
if "agents" in kwargs:
|
|
206
|
+
agent_ids = self._extract_ids(kwargs["agents"])
|
|
207
|
+
if agent_ids:
|
|
208
|
+
update_data["agents"] = agent_ids
|
|
209
|
+
elif current_agent.agents:
|
|
210
|
+
update_data["agents"] = [
|
|
211
|
+
agent["id"] if isinstance(agent, dict) else agent
|
|
212
|
+
for agent in current_agent.agents
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
# Add any other kwargs
|
|
216
|
+
for key, value in kwargs.items():
|
|
217
|
+
if key not in ["tools", "agents"]:
|
|
218
|
+
update_data[key] = value
|
|
219
|
+
|
|
220
|
+
# Send the complete payload
|
|
221
|
+
data = self._request("PUT", f"/agents/{agent_id}", json=update_data)
|
|
222
|
+
return Agent(**data)._set_client(self)
|
|
223
|
+
|
|
224
|
+
def delete_agent(self, agent_id: str) -> None:
|
|
225
|
+
"""Delete an agent."""
|
|
226
|
+
self._request("DELETE", f"/agents/{agent_id}")
|
|
227
|
+
|
|
228
|
+
def run_agent(
|
|
229
|
+
self,
|
|
230
|
+
agent_id: str,
|
|
231
|
+
message: str,
|
|
232
|
+
files: list[str | BinaryIO] | None = None,
|
|
233
|
+
tty: bool = False,
|
|
234
|
+
stream: bool = True,
|
|
235
|
+
*,
|
|
236
|
+
renderer: RichStreamRenderer | str | None = "auto",
|
|
237
|
+
verbose: bool = False,
|
|
238
|
+
**kwargs,
|
|
239
|
+
) -> str:
|
|
240
|
+
"""Run an agent with a message, streaming via a renderer."""
|
|
241
|
+
# Prepare multipart data if files are provided
|
|
242
|
+
form_data = None
|
|
243
|
+
headers = {}
|
|
244
|
+
|
|
245
|
+
if files:
|
|
246
|
+
form_data = self._prepare_multipart_data(message, files)
|
|
247
|
+
headers["Content-Type"] = "multipart/form-data"
|
|
248
|
+
payload = None
|
|
249
|
+
else:
|
|
250
|
+
payload = {"input": message, **kwargs}
|
|
251
|
+
if tty:
|
|
252
|
+
payload["tty"] = True
|
|
253
|
+
if not stream:
|
|
254
|
+
payload["stream"] = False
|
|
255
|
+
|
|
256
|
+
# Choose renderer (even if stream=False, we can still format final)
|
|
257
|
+
r = _select_renderer(renderer, verbose=verbose)
|
|
258
|
+
|
|
259
|
+
# Try to set some meta early; refine as we receive events
|
|
260
|
+
meta = {
|
|
261
|
+
"agent_name": kwargs.get("agent_name", agent_id),
|
|
262
|
+
"model": kwargs.get("model"),
|
|
263
|
+
"run_id": None,
|
|
264
|
+
"input_message": message, # Add the original query for context
|
|
265
|
+
}
|
|
266
|
+
r.on_start(meta)
|
|
267
|
+
|
|
268
|
+
final_text = ""
|
|
269
|
+
stats_usage = {}
|
|
270
|
+
started_monotonic = None
|
|
271
|
+
finished_monotonic = None
|
|
272
|
+
|
|
273
|
+
with self.http_client.stream(
|
|
274
|
+
"POST",
|
|
275
|
+
f"/agents/{agent_id}/run",
|
|
276
|
+
json=payload if not files else None,
|
|
277
|
+
data=form_data.get("data") if files else None,
|
|
278
|
+
files=form_data.get("files") if files else None,
|
|
279
|
+
headers=headers,
|
|
280
|
+
) as response:
|
|
281
|
+
response.raise_for_status()
|
|
282
|
+
|
|
283
|
+
# capture request id if provided
|
|
284
|
+
req_id = response.headers.get("x-request-id") or response.headers.get(
|
|
285
|
+
"x-run-id"
|
|
286
|
+
)
|
|
287
|
+
if req_id:
|
|
288
|
+
meta["run_id"] = req_id
|
|
289
|
+
r.on_start(meta) # refresh header with run_id
|
|
290
|
+
|
|
291
|
+
for event in self._iter_sse_events(response):
|
|
292
|
+
try:
|
|
293
|
+
ev = json.loads(event["data"])
|
|
294
|
+
except json.JSONDecodeError:
|
|
295
|
+
logger.debug("Non-JSON SSE fragment skipped")
|
|
296
|
+
continue
|
|
297
|
+
|
|
298
|
+
# Start timer at first meaningful event
|
|
299
|
+
if started_monotonic is None and (
|
|
300
|
+
"content" in ev or "status" in ev or ev.get("metadata")
|
|
301
|
+
):
|
|
302
|
+
from time import monotonic
|
|
303
|
+
|
|
304
|
+
started_monotonic = monotonic()
|
|
305
|
+
|
|
306
|
+
kind = (ev.get("metadata") or {}).get("kind")
|
|
307
|
+
|
|
308
|
+
# Hide "artifact" chatter
|
|
309
|
+
if kind == "artifact":
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
# Accumulate assistant content, but do not print here
|
|
313
|
+
if "content" in ev and ev["content"]:
|
|
314
|
+
# Filter weird backend text like "Artifact received: ..."
|
|
315
|
+
if not ev["content"].startswith("Artifact received:"):
|
|
316
|
+
final_text = ev["content"] # replace with latest
|
|
317
|
+
r.on_event(ev)
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
# Step / tool events
|
|
321
|
+
if kind == "agent_step":
|
|
322
|
+
r.on_event(ev)
|
|
323
|
+
continue
|
|
324
|
+
|
|
325
|
+
# Statuses: forward to renderer (it decides to collapse)
|
|
326
|
+
if "status" in ev:
|
|
327
|
+
r.on_event(ev)
|
|
328
|
+
continue
|
|
329
|
+
|
|
330
|
+
# Usage/cost event (if your backend emits it)
|
|
331
|
+
if kind == "usage":
|
|
332
|
+
stats_usage.update(ev.get("usage") or {})
|
|
333
|
+
continue
|
|
334
|
+
|
|
335
|
+
# Model/run info (if emitted mid-stream)
|
|
336
|
+
if kind == "run_info":
|
|
337
|
+
if ev.get("model"):
|
|
338
|
+
meta["model"] = ev["model"]
|
|
339
|
+
r.on_start(meta)
|
|
340
|
+
if ev.get("run_id"):
|
|
341
|
+
meta["run_id"] = ev["run_id"]
|
|
342
|
+
r.on_start(meta)
|
|
343
|
+
|
|
344
|
+
from time import monotonic
|
|
345
|
+
|
|
346
|
+
finished_monotonic = monotonic()
|
|
347
|
+
|
|
348
|
+
# Finalize stats
|
|
349
|
+
from glaip_sdk.utils.run_renderer import RunStats
|
|
350
|
+
|
|
351
|
+
st = RunStats()
|
|
352
|
+
st.started_at = started_monotonic or st.started_at
|
|
353
|
+
st.finished_at = finished_monotonic or st.started_at
|
|
354
|
+
st.usage = stats_usage
|
|
355
|
+
|
|
356
|
+
r.on_complete(final_text or "No response content received.", st)
|
|
357
|
+
return final_text or "No response content received."
|
|
358
|
+
|
|
359
|
+
def _iter_sse_events(self, response: httpx.Response):
|
|
360
|
+
"""Iterate over Server-Sent Events with proper parsing."""
|
|
361
|
+
buf = []
|
|
362
|
+
event_type = None
|
|
363
|
+
event_id = None
|
|
364
|
+
|
|
365
|
+
for raw in response.iter_lines():
|
|
366
|
+
line = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
|
367
|
+
|
|
368
|
+
if line == "":
|
|
369
|
+
if buf:
|
|
370
|
+
data = "\n".join(buf)
|
|
371
|
+
yield {
|
|
372
|
+
"event": event_type or "message",
|
|
373
|
+
"id": event_id,
|
|
374
|
+
"data": data,
|
|
375
|
+
}
|
|
376
|
+
buf, event_type, event_id = [], None, None
|
|
377
|
+
continue
|
|
378
|
+
|
|
379
|
+
if line.startswith(":"): # comment
|
|
380
|
+
continue
|
|
381
|
+
if line.startswith("data:"):
|
|
382
|
+
buf.append(line[5:].lstrip())
|
|
383
|
+
elif line.startswith("event:"):
|
|
384
|
+
event_type = line[6:].strip() or None
|
|
385
|
+
elif line.startswith("id:"):
|
|
386
|
+
event_id = line[3:].strip() or None
|
|
387
|
+
|
|
388
|
+
# Flush any remaining data
|
|
389
|
+
if buf:
|
|
390
|
+
yield {
|
|
391
|
+
"event": event_type or "message",
|
|
392
|
+
"id": event_id,
|
|
393
|
+
"data": "\n".join(buf),
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
def _prepare_multipart_data(
|
|
397
|
+
self, message: str, files: list[str | BinaryIO]
|
|
398
|
+
) -> dict[str, Any]:
|
|
399
|
+
"""Prepare multipart form data for file uploads."""
|
|
400
|
+
from pathlib import Path
|
|
401
|
+
|
|
402
|
+
form_data = {"data": {"message": message}}
|
|
403
|
+
file_list = []
|
|
404
|
+
|
|
405
|
+
for file_item in files:
|
|
406
|
+
if isinstance(file_item, str):
|
|
407
|
+
# File path - let httpx stream the file handle
|
|
408
|
+
file_path = Path(file_item)
|
|
409
|
+
if not file_path.exists():
|
|
410
|
+
raise FileNotFoundError(f"File not found: {file_item}")
|
|
411
|
+
|
|
412
|
+
file_list.append(
|
|
413
|
+
(
|
|
414
|
+
"files",
|
|
415
|
+
(
|
|
416
|
+
file_path.name,
|
|
417
|
+
open(file_path, "rb"),
|
|
418
|
+
"application/octet-stream",
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
)
|
|
422
|
+
else:
|
|
423
|
+
# File-like object
|
|
424
|
+
if hasattr(file_item, "name"):
|
|
425
|
+
filename = getattr(file_item, "name", "file")
|
|
426
|
+
else:
|
|
427
|
+
filename = "file"
|
|
428
|
+
|
|
429
|
+
if hasattr(file_item, "read"):
|
|
430
|
+
# For file-like objects, we need to read them since httpx expects bytes
|
|
431
|
+
file_content = file_item.read()
|
|
432
|
+
file_list.append(
|
|
433
|
+
("files", (filename, file_content, "application/octet-stream"))
|
|
434
|
+
)
|
|
435
|
+
else:
|
|
436
|
+
raise ValueError(f"Invalid file object: {file_item}")
|
|
437
|
+
|
|
438
|
+
if file_list:
|
|
439
|
+
form_data["files"] = file_list
|
|
440
|
+
|
|
441
|
+
return form_data
|