mh-service-kit 0.1.0__tar.gz

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.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.3
2
+ Name: mh-service-kit
3
+ Version: 0.1.0
4
+ Summary: SDK for building standalone agent & tool services in the minimal-harness ecosystem
5
+ Requires-Dist: fastapi>=0.115.0
6
+ Requires-Dist: uvicorn[standard]>=0.30.0
7
+ Requires-Dist: openai>=1.0.0
8
+ Requires-Dist: pydantic>=2.0.0
9
+ Requires-Dist: minimal-harness>=0.6.2
10
+ Requires-Python: >=3.12
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "mh-service-kit"
3
+ version = "0.1.0"
4
+ description = "SDK for building standalone agent & tool services in the minimal-harness ecosystem"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "fastapi>=0.115.0",
8
+ "uvicorn[standard]>=0.30.0",
9
+ "openai>=1.0.0",
10
+ "pydantic>=2.0.0",
11
+ "minimal-harness>=0.6.2",
12
+ ]
13
+
14
+ [build-system]
15
+ requires = ["uv_build>=0.10.12,<0.12"]
16
+ build-backend = "uv_build"
17
+
18
+ [tool.setuptools.packages.find]
19
+ where = ["src"]
20
+
21
+ [tool.uv.sources]
22
+ minimal-harness = { workspace = true }
@@ -0,0 +1,14 @@
1
+ from minimal_harness.types import ToolResult
2
+
3
+ from mh_service_kit.app import ServiceApp
4
+ from mh_service_kit.context import ToolContext
5
+ from mh_service_kit.m2m_auth import M2MAuthProvider
6
+ from mh_service_kit.models import parameters_from_model
7
+
8
+ __all__ = [
9
+ "M2MAuthProvider",
10
+ "ServiceApp",
11
+ "ToolContext",
12
+ "ToolResult",
13
+ "parameters_from_model",
14
+ ]
@@ -0,0 +1,419 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import logging
5
+ from collections.abc import AsyncGenerator
6
+ from typing import Any, Callable
7
+
8
+ from fastapi import Depends, FastAPI, Header, HTTPException, Request
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import HTMLResponse, StreamingResponse
11
+ from minimal_harness.client.logging_setup import setup_service_logging
12
+ from minimal_harness.types import ToolResult
13
+ from openai import AsyncOpenAI
14
+ from pydantic import BaseModel
15
+
16
+ from mh_service_kit.context import ToolContext
17
+ from mh_service_kit.llm import get_runner
18
+ from mh_service_kit.llm import reset as reset_llm
19
+ from mh_service_kit.locale import parse_locale, resolve_locale
20
+ from mh_service_kit.models import (
21
+ AgentRunRequest,
22
+ ToolExecuteRequest,
23
+ parameters_from_model,
24
+ validate_args,
25
+ )
26
+ from mh_service_kit.playground import PLAYGROUND_HTML
27
+ from mh_service_kit.m2m_auth import M2MAuthProvider, _DefaultM2MAuthProvider
28
+ from mh_service_kit.sse import sse_line
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ class ServiceApp:
34
+ def __init__(
35
+ self,
36
+ *,
37
+ title: str = "Agent & Tool Service",
38
+ version: str = "0.1.0",
39
+ cors_origins: list[str] | None = None,
40
+ default_locale: str = "zh",
41
+ dev_mode: bool = True,
42
+ llm_client: AsyncOpenAI | None = None,
43
+ runner: Any | None = None,
44
+ llm_api_key: str = "",
45
+ llm_base_url: str = "",
46
+ m2m_auth_provider: M2MAuthProvider | None = None,
47
+ ):
48
+ self._title = title
49
+ self._version = version
50
+ self._cors_origins = cors_origins or ["http://localhost:5173"]
51
+ self._default_locale = default_locale
52
+ self._dev_mode = dev_mode
53
+ self._llm_client = llm_client
54
+ self._runner = runner
55
+ self._llm_api_key = llm_api_key
56
+ self._llm_base_url = llm_base_url
57
+ self._m2m_auth_provider = m2m_auth_provider
58
+
59
+ self._agents: dict[str, dict[str, Any]] = {}
60
+ self._tools: dict[str, dict[str, Any]] = {}
61
+ self._handlers: dict[str, Callable] = {}
62
+ self._params_models: dict[str, type[BaseModel] | None] = {}
63
+
64
+ # ── Agent registration ──────────────────────────────────────────
65
+
66
+ def add_agent(
67
+ self,
68
+ *,
69
+ name: str,
70
+ display_name: str = "",
71
+ description: str = "",
72
+ system_prompt: str = "",
73
+ display_name_locale: dict[str, str] | None = None,
74
+ description_locale: dict[str, str] | None = None,
75
+ system_prompt_locale: dict[str, str] | None = None,
76
+ ) -> None:
77
+ self._agents[name] = {
78
+ "name": name,
79
+ "display_name": display_name or name,
80
+ "display_name_locale": display_name_locale,
81
+ "description": description,
82
+ "description_locale": description_locale,
83
+ "system_prompt": system_prompt,
84
+ "system_prompt_locale": system_prompt_locale,
85
+ }
86
+
87
+ # ── Tool registration ───────────────────────────────────────────
88
+
89
+ def add_tool(
90
+ self,
91
+ *,
92
+ name: str,
93
+ display_name: str = "",
94
+ description: str = "",
95
+ parameters: dict | None = None,
96
+ params_model: type[BaseModel] | None = None,
97
+ handler: Callable[[dict], str]
98
+ | Callable[[dict], AsyncGenerator[str, None]]
99
+ | None = None,
100
+ display_name_locale: dict[str, str] | None = None,
101
+ description_locale: dict[str, str] | None = None,
102
+ ) -> None:
103
+ if params_model is not None:
104
+ resolved_params: dict = parameters_from_model(params_model)
105
+ else:
106
+ resolved_params = parameters or {}
107
+
108
+ self._tools[name] = {
109
+ "name": name,
110
+ "display_name": display_name or name,
111
+ "display_name_locale": display_name_locale,
112
+ "description": description,
113
+ "description_locale": description_locale,
114
+ "parameters": resolved_params,
115
+ }
116
+ self._params_models[name] = params_model
117
+ if handler:
118
+ self._handlers[name] = handler
119
+
120
+ # ── LLM helpers ─────────────────────────────────────────────────
121
+
122
+ def _resolve_runner(self) -> Any:
123
+ if self._runner is not None:
124
+ return self._runner
125
+ client = self._llm_client
126
+ if client is None:
127
+ client = self._get_default_client()
128
+ return get_runner(llm_client=client)
129
+
130
+ def _get_default_client(self) -> AsyncOpenAI:
131
+ from os import environ
132
+
133
+ api_key = self._llm_api_key or environ.get("MH_API_KEY", "")
134
+ base_url = self._llm_base_url or "https://aihubmix.com/v1"
135
+ return AsyncOpenAI(api_key=api_key, base_url=base_url)
136
+
137
+ def reset_llm_singletons(self) -> None:
138
+ reset_llm()
139
+
140
+ # ── Build FastAPI app ───────────────────────────────────────────
141
+
142
+ def build(self) -> FastAPI:
143
+ app = FastAPI(title=self._title, version=self._version)
144
+ app.add_middleware(
145
+ CORSMiddleware,
146
+ allow_origins=self._cors_origins,
147
+ allow_credentials=True,
148
+ allow_methods=["*"],
149
+ allow_headers=["*"],
150
+ )
151
+
152
+ if self._dev_mode:
153
+
154
+ @app.get("/playground", include_in_schema=False)
155
+ async def playground():
156
+ return HTMLResponse(PLAYGROUND_HTML)
157
+
158
+ agents = self._agents
159
+ tools = self._tools
160
+ handlers = self._handlers
161
+ resolve = resolve_locale
162
+ default_locale = self._default_locale
163
+ resolve_runner = self._resolve_runner
164
+ m2m_provider = self._m2m_auth_provider or _DefaultM2MAuthProvider()
165
+
166
+ async def verify_m2m(request: Request) -> str:
167
+ app_id = await m2m_provider.authenticate(request)
168
+ if app_id is None:
169
+ raise HTTPException(
170
+ status_code=401, detail="M2M authentication required"
171
+ )
172
+ return app_id
173
+
174
+ # ── GET /agents ─────────────────────────────────────────────
175
+
176
+ @app.get("/agents")
177
+ async def list_agents(
178
+ accept_language: str | None = Header(None, alias="Accept-Language"),
179
+ ):
180
+ locale = parse_locale(accept_language) or default_locale
181
+ result = [
182
+ {
183
+ "name": cfg["name"],
184
+ "display_name": resolve(
185
+ cfg["display_name"], cfg.get("display_name_locale"), locale
186
+ ),
187
+ "description": resolve(
188
+ cfg["description"], cfg.get("description_locale"), locale
189
+ ),
190
+ }
191
+ for cfg in agents.values()
192
+ ]
193
+ logger.debug(
194
+ "OUTBOUND GET /agents — locale=%s returned=%d",
195
+ locale,
196
+ len(result),
197
+ )
198
+ return result
199
+
200
+ # ── GET /tools ──────────────────────────────────────────────
201
+
202
+ @app.get("/tools")
203
+ async def list_tools(
204
+ accept_language: str | None = Header(None, alias="Accept-Language"),
205
+ ):
206
+ locale = parse_locale(accept_language) or default_locale
207
+ result = [
208
+ {
209
+ **t,
210
+ "display_name": resolve(
211
+ t["display_name"], t.get("display_name_locale"), locale
212
+ ),
213
+ "description": resolve(
214
+ t["description"], t.get("description_locale"), locale
215
+ ),
216
+ }
217
+ for t in tools.values()
218
+ ]
219
+ logger.debug(
220
+ "OUTBOUND GET /tools — locale=%s returned=%d",
221
+ locale,
222
+ len(result),
223
+ )
224
+ return result
225
+
226
+ # ── POST /agent/{agent_name}/run ────────────────────────────
227
+
228
+ @app.post("/agent/{agent_name}/run")
229
+ async def run_agent(
230
+ agent_name: str,
231
+ body: AgentRunRequest,
232
+ _app_id: str = Depends(verify_m2m),
233
+ accept_language: str | None = Header(None, alias="Accept-Language"),
234
+ ):
235
+ logger.debug(
236
+ "INBOUND POST /agent/%s/run — user_input_count=%d tools_count=%d memory_count=%d locale=%s",
237
+ agent_name,
238
+ len(body.user_input),
239
+ len(body.tools),
240
+ len(body.memory),
241
+ accept_language,
242
+ )
243
+ agent = agents.get(agent_name)
244
+ if agent is None:
245
+ logger.warning("agent not found: %s", agent_name)
246
+ raise HTTPException(
247
+ status_code=404, detail=f"Agent {agent_name} not found"
248
+ )
249
+
250
+ locale = parse_locale(accept_language) or default_locale
251
+ system_prompt = body.system_prompt or resolve(
252
+ agent["system_prompt"], agent.get("system_prompt_locale"), locale
253
+ )
254
+
255
+ runner = resolve_runner()
256
+
257
+ async def event_stream():
258
+ event_count = 0
259
+ async for line in runner.run(
260
+ user_input=body.user_input,
261
+ tools_schema=body.tools,
262
+ memory_messages=body.memory,
263
+ system_prompt=system_prompt,
264
+ config=body.config,
265
+ ):
266
+ event_count += 1
267
+ logger.debug(
268
+ "OUTBOUND /agent/%s/run event — event_count=%d line_preview=%r",
269
+ agent_name,
270
+ event_count,
271
+ line[:120] if isinstance(line, str) else line,
272
+ )
273
+ yield line
274
+ logger.debug(
275
+ "OUTBOUND /agent/%s/run complete — total_events=%d",
276
+ agent_name,
277
+ event_count,
278
+ )
279
+
280
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
281
+
282
+ # ── POST /tools/{tool_name}/execute ─────────────────────────
283
+
284
+ @app.post("/tools/{tool_name}/execute")
285
+ async def execute_tool(
286
+ tool_name: str,
287
+ body: ToolExecuteRequest,
288
+ request: Request,
289
+ _app_id: str = Depends(verify_m2m),
290
+ ):
291
+ logger.debug(
292
+ "INBOUND POST /tools/%s/execute — args_keys=%s",
293
+ tool_name,
294
+ list(body.args.keys()) if isinstance(body.args, dict) else [],
295
+ )
296
+ handler = handlers.get(tool_name)
297
+ if handler is None:
298
+ logger.warning("tool not found: %s", tool_name)
299
+
300
+ async def error_stream():
301
+ yield sse_line("tool_end", f"Tool {tool_name} not found")
302
+
303
+ return StreamingResponse(error_stream(), media_type="text/event-stream")
304
+
305
+ params_model = self._params_models.get(tool_name)
306
+ tool_config = tools.get(tool_name, {})
307
+ validated, error = validate_args(
308
+ body.args, tool_config.get("parameters"), params_model
309
+ )
310
+ if error is not None:
311
+ logger.warning(
312
+ "tool arg validation failed: tool=%s error=%s", tool_name, error
313
+ )
314
+
315
+ async def validation_error_stream():
316
+ yield sse_line("tool_end", f"Validation error: {error}")
317
+
318
+ return StreamingResponse(
319
+ validation_error_stream(), media_type="text/event-stream"
320
+ )
321
+
322
+ context = ToolContext(
323
+ headers={k.lower(): v for k, v in request.headers.items()},
324
+ )
325
+ needs_context = "context" in inspect.signature(handler).parameters
326
+
327
+ async def event_stream():
328
+ try:
329
+ if inspect.isasyncgenfunction(handler):
330
+ final_result = ""
331
+ gen = (
332
+ handler(validated, context=context)
333
+ if needs_context
334
+ else handler(validated)
335
+ )
336
+ async for chunk in gen:
337
+ logger.debug(
338
+ "OUTBOUND /tools/%s/tool_progress — chunk_type=%s chunk_preview=%r",
339
+ tool_name,
340
+ type(chunk).__name__,
341
+ str(chunk)[:80],
342
+ )
343
+ yield sse_line("tool_progress", chunk)
344
+ final_result = chunk
345
+ result = final_result
346
+ elif inspect.iscoroutinefunction(handler):
347
+ result = (
348
+ await handler(validated, context=context)
349
+ if needs_context
350
+ else await handler(validated)
351
+ )
352
+ logger.debug(
353
+ "OUTBOUND /tools/%s/tool_progress — result_type=%s result_preview=%r",
354
+ tool_name,
355
+ type(result).__name__,
356
+ str(result)[:80],
357
+ )
358
+ yield sse_line("tool_progress", result)
359
+ else:
360
+ result = (
361
+ handler(validated, context=context)
362
+ if needs_context
363
+ else handler(validated)
364
+ )
365
+ logger.debug(
366
+ "OUTBOUND /tools/%s/tool_progress — result_type=%s result_preview=%r",
367
+ tool_name,
368
+ type(result).__name__,
369
+ str(result)[:80],
370
+ )
371
+ yield sse_line("tool_progress", result)
372
+
373
+ logger.debug(
374
+ "OUTBOUND /tools/%s/tool_end — result_preview=%r",
375
+ tool_name,
376
+ str(result)[:120],
377
+ )
378
+ if isinstance(result, ToolResult):
379
+ yield sse_line(
380
+ "tool_end",
381
+ {
382
+ "content": result.content,
383
+ "__meta": result.meta,
384
+ "__stop": result.stop,
385
+ },
386
+ )
387
+ else:
388
+ yield sse_line("tool_end", result)
389
+ except Exception as e:
390
+ logger.exception(
391
+ "Tool %s execution failed — error=%s",
392
+ tool_name,
393
+ e,
394
+ )
395
+ yield sse_line(
396
+ "error",
397
+ {"message": f"Tool execution failed: {e}"},
398
+ )
399
+ yield sse_line(
400
+ "tool_end",
401
+ {
402
+ "tool_call": {"id": body.tool_call_id},
403
+ "result": f"Error: {e}",
404
+ },
405
+ )
406
+
407
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
408
+
409
+ return app
410
+
411
+ # ── Run server ──────────────────────────────────────────────────
412
+
413
+ def run(self, host: str = "0.0.0.0", port: int = 8003) -> None:
414
+ import uvicorn
415
+
416
+ setup_service_logging()
417
+ logger.info("starting service — host=%s port=%d", host, port)
418
+ app = self.build()
419
+ uvicorn.run(app, host=host, port=port)
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass
7
+ class ToolContext:
8
+ """HTTP request context passed to tool handlers that declare a ``context`` parameter.
9
+
10
+ Carries the full original request headers (keys lowercased) so tool
11
+ implementations can extract whatever HTTP-level information they need
12
+ — cookies, auth tokens, custom headers, etc. — without the framework
13
+ having to predict every need.
14
+ """
15
+
16
+ headers: dict[str, str] = field(default_factory=dict)
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from openai import AsyncOpenAI
4
+ from minimal_harness.agent.runner import SSEAgentRunner
5
+
6
+ _client: AsyncOpenAI | None = None
7
+ _runner: SSEAgentRunner | None = None
8
+
9
+
10
+ def get_client(api_key: str = "", base_url: str = "") -> AsyncOpenAI:
11
+ global _client
12
+ if _client is None:
13
+ _client = AsyncOpenAI(api_key=api_key, base_url=base_url)
14
+ return _client
15
+
16
+
17
+ def get_runner(llm_client: AsyncOpenAI | None = None) -> SSEAgentRunner:
18
+ global _runner
19
+ if _runner is None:
20
+ _runner = SSEAgentRunner(llm_client=llm_client)
21
+ return _runner
22
+
23
+
24
+ def reset() -> None:
25
+ global _client, _runner
26
+ _client = None
27
+ _runner = None
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ def parse_locale(accept_language: str | None = None) -> str:
5
+ if accept_language:
6
+ lang = accept_language.split(",")[0].split(";")[0].strip().lower()
7
+ if lang in ("zh", "en"):
8
+ return lang
9
+ return "zh"
10
+
11
+
12
+ def resolve_locale(
13
+ value: str,
14
+ value_locale: dict[str, str] | None,
15
+ locale: str,
16
+ ) -> str:
17
+ if locale and value_locale and locale in value_locale:
18
+ return value_locale[locale]
19
+ return value
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any, Protocol, runtime_checkable
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+
9
+ @runtime_checkable
10
+ class M2MAuthProvider(Protocol):
11
+ """机机接口鉴权提供者。
12
+
13
+ 在 agent 调用端点(``POST /agent/{name}/run``)和
14
+ tool 执行端点(``POST /tools/{name}/execute``)被调用时
15
+ 验证调用方身份。
16
+
17
+ 客户企业部署时实现此 protocol,通过 SOA、API Key、mTLS
18
+ 或其他机制验证调用方应用身份。接收原始 ``Request`` 对象,
19
+ 可自行决定检查方式(header、cookie、mTLS 证书等)。
20
+
21
+ 返回 ``app_id`` 表示鉴权通过,返回 ``None`` 表示失败(返回 401)。
22
+ """
23
+
24
+ async def authenticate(self, request: Any) -> str | None:
25
+ """验证机机调用方身份。
26
+
27
+ Args:
28
+ request: 当前 FastAPI Request 对象。
29
+
30
+ Returns:
31
+ app_id 表示鉴权通过,``None`` 表示鉴权失败(调用方返回 401)。
32
+ """
33
+ ...
34
+
35
+ async def close(self) -> None:
36
+ """释放资源(连接池、文件句柄等)。"""
37
+ ...
38
+
39
+
40
+ class _DefaultM2MAuthProvider:
41
+ """默认实现——不做判断,仅记录请求信息。
42
+
43
+ 不执行任何鉴权校验,一律放行。仅以 INFO 级别打印请求的
44
+ method、path 和关键 header,方便开发阶段观察流量。
45
+
46
+ 生产环境必须替换为实际的 M2M 鉴权实现(如 SOA、API Key
47
+ 签名校验)。
48
+ """
49
+
50
+ async def close(self) -> None:
51
+ pass
52
+
53
+ async def authenticate(self, request: Any) -> str | None:
54
+ logger.info(
55
+ "M2M request — method=%s path=%s headers=%s",
56
+ request.method,
57
+ request.url.path,
58
+ dict(request.headers),
59
+ )
60
+ return "anonymous"
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel
6
+
7
+
8
+ class AgentRunRequest(BaseModel):
9
+ user_input: list[dict] = []
10
+ tools: list[dict] = []
11
+ memory: list[dict] = []
12
+ system_prompt: str = ""
13
+ config: dict = {}
14
+
15
+
16
+ class ToolExecuteRequest(BaseModel):
17
+ args: dict = {}
18
+ tool_call_id: str = ""
19
+
20
+
21
+ def parameters_from_model(model: type[BaseModel]) -> dict[str, Any]:
22
+ """Convert a Pydantic BaseModel to an OpenAI-compatible tool parameters JSON Schema dict.
23
+
24
+ Example:
25
+ >>> from pydantic import BaseModel, Field
26
+ >>> class CalcParams(BaseModel):
27
+ ... expression: str = Field(description="Math expression")
28
+ >>> parameters_from_model(CalcParams)
29
+ {'type': 'object', 'properties': {'expression': {'type': 'string', 'description': 'Math expression', 'title': 'Expression'}}, 'required': ['expression']}
30
+ """
31
+ schema = model.model_json_schema()
32
+ return {
33
+ "type": "object",
34
+ "properties": schema.get("properties", {}),
35
+ "required": schema.get("required", []),
36
+ }
37
+
38
+
39
+ def validate_args(
40
+ args: dict[str, Any],
41
+ parameters: dict[str, Any] | None,
42
+ params_model: type[BaseModel] | None,
43
+ ) -> tuple[dict[str, Any] | None, str | None]:
44
+ """Validate tool arguments against the declared schema.
45
+
46
+ Returns (validated_args, error_message). If valid, validated_args is the
47
+ cleaned/coerced dict and error_message is None. Otherwise validated_args
48
+ is None and error_message describes the problem.
49
+ """
50
+ if params_model is not None:
51
+ try:
52
+ instance = params_model.model_validate(args)
53
+ return instance.model_dump(mode="python"), None
54
+ except Exception as e:
55
+ return None, str(e)
56
+
57
+ if parameters:
58
+ required = parameters.get("required", [])
59
+ props = parameters.get("properties", {})
60
+
61
+ missing = [f for f in required if f not in args]
62
+ if missing:
63
+ return None, f"Missing required parameters: {', '.join(missing)}"
64
+
65
+ for key, value in args.items():
66
+ prop = props.get(key, {})
67
+ enum_vals = prop.get("enum")
68
+ if enum_vals is not None and value not in enum_vals:
69
+ return (
70
+ None,
71
+ f"Parameter '{key}' must be one of: {', '.join(str(v) for v in enum_vals)}",
72
+ )
73
+
74
+ return args, None
@@ -0,0 +1,353 @@
1
+ PLAYGROUND_HTML = r"""<!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Developer Playground</title>
7
+ <style>
8
+ *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
9
+ body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,sans-serif;background:#f5f5f7;color:#1d1d1f;padding:24px;min-height:100vh}
10
+ .container{max-width:960px;margin:0 auto}
11
+ h1{font-size:22px;font-weight:600;margin-bottom:4px}
12
+ .sub{color:#86868b;font-size:13px;margin-bottom:24px}
13
+ .card{background:#fff;border-radius:12px;padding:20px;margin-bottom:16px;box-shadow:0 1px 3px rgba(0,0,0,.08)}
14
+ .card-title{font-size:14px;font-weight:600;margin-bottom:12px;color:#555}
15
+ .row{display:flex;gap:12px;flex-wrap:wrap;align-items:end}
16
+ .col{flex:1;min-width:180px}
17
+ label{display:block;font-size:12px;font-weight:500;color:#555;margin-bottom:4px}
18
+ select,input[type=text],textarea{width:100%;padding:8px 10px;border:1px solid #d2d2d7;border-radius:8px;font-size:13px;background:#fff;outline:none;transition:border-color .15s}
19
+ select:focus,input:focus,textarea:focus{border-color:#0071e3}
20
+ textarea{font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;font-size:12px;resize:vertical;min-height:38px}
21
+ .btn{padding:8px 20px;border:none;border-radius:8px;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .15s;display:inline-flex;align-items:center;gap:6px}
22
+ .btn-primary{background:#0071e3;color:#fff}
23
+ .btn-primary:hover{opacity:.85}
24
+ .btn-primary:disabled{opacity:.4;cursor:not-allowed}
25
+ .btn-danger{background:#ff3b30;color:#fff}
26
+ .btn-danger:hover{opacity:.85}
27
+ .params-area{margin-top:12px}
28
+ .param-row{margin-bottom:8px}
29
+ .param-row label{font-size:12px;font-weight:500;color:#555;margin-bottom:2px}
30
+ .param-row .param-type{font-size:11px;color:#86868b;margin-left:6px;font-weight:400}
31
+ .param-row .param-desc{font-size:11px;color:#86868b;margin-top:1px}
32
+ .tabs{display:flex;gap:0;margin-bottom:16px;border-bottom:1px solid #d2d2d7}
33
+ .tab{padding:8px 20px;font-size:13px;cursor:pointer;border:none;background:none;color:#86868b;border-bottom:2px solid transparent;transition:all .15s}
34
+ .tab.active{color:#0071e3;border-bottom-color:#0071e3;font-weight:600}
35
+ .tab:hover{color:#1d1d1f}
36
+ .toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:12px}
37
+ .toolbar .status-badge{padding:2px 10px;border-radius:12px;font-size:11px;font-weight:500;background:#e8e8ed;color:#555}
38
+ .toolbar .status-badge.running{background:#0071e3;color:#fff}
39
+ .toolbar .status-badge.done{background:#30d158;color:#fff}
40
+ .toolbar .status-badge.error{background:#ff3b30;color:#fff}
41
+ .output-area{background:#1d1d1f;border-radius:10px;padding:16px;font-family:ui-monospace,"SF Mono",Menlo,Consolas,monospace;font-size:12px;line-height:1.6;color:#e8e8ed;max-height:500px;overflow:auto;white-space:pre-wrap;word-break:break-all;position:relative}
42
+ .output-area .line{padding:1px 0;display:flex}
43
+ .output-area .line .tag{flex:0 0 85px;padding:0 6px;border-radius:4px;font-size:11px;font-weight:500;text-align:center;margin-right:8px}
44
+ .tag-start{background:#5e5ce6;color:#fff}
45
+ .tag-progress{background:#0071e3;color:#fff}
46
+ .tag-end{background:#30d158;color:#1d1d1f}
47
+ .tag-error{background:#ff3b30;color:#fff}
48
+ .tag-llm{background:#ff9f0a;color:#1d1d1f}
49
+ .tag-info{background:#48484a;color:#e8e8ed}
50
+ .output-placeholder{color:#48484a;font-style:italic}
51
+ .hidden{display:none !important}
52
+ .clear-btn{position:absolute;top:8px;right:8px;background:#48484a;border:none;color:#e8e8ed;font-size:11px;padding:4px 10px;border-radius:6px;cursor:pointer;opacity:.6;transition:opacity .15s}
53
+ .clear-btn:hover{opacity:1}
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <div class="container">
58
+ <h1>🛠️ Developer Playground</h1>
59
+ <p class="sub">Invoke tools and agents, observe SSE events in real-time</p>
60
+
61
+ <div class="card">
62
+ <div class="tabs">
63
+ <button class="tab active" data-tab="tool" onclick="switchTab('tool')">Tool</button>
64
+ <button class="tab" data-tab="agent" onclick="switchTab('agent')">Agent</button>
65
+ </div>
66
+
67
+ <div id="panel-tool">
68
+ <div class="row">
69
+ <div class="col">
70
+ <label>Tool</label>
71
+ <select id="tool-select" onchange="onToolChange()"><option value="">— select —</option></select>
72
+ </div>
73
+ </div>
74
+ <div class="params-area" id="tool-params"></div>
75
+ </div>
76
+
77
+ <div id="panel-agent" class="hidden">
78
+ <div class="row">
79
+ <div class="col">
80
+ <label>Agent</label>
81
+ <select id="agent-select" onchange="onAgentChange()"><option value="">— select —</option></select>
82
+ </div>
83
+ </div>
84
+ <div class="params-area" id="agent-params"></div>
85
+ </div>
86
+
87
+ <div class="toolbar" style="margin-top:16px">
88
+ <button class="btn btn-primary" id="invoke-btn" onclick="invoke()">▶ Invoke</button>
89
+ <button class="btn btn-danger hidden" id="cancel-btn" onclick="cancelInvoke()">■ Cancel</button>
90
+ <span class="status-badge hidden" id="status-badge">Idle</span>
91
+ </div>
92
+ </div>
93
+
94
+ <div class="card" style="position:relative">
95
+ <div class="card-title">Output (SSE Stream)</div>
96
+ <div class="output-area" id="output"><span class="output-placeholder">Select an endpoint and click Invoke to see results…</span></div>
97
+ <button class="clear-btn" onclick="clearOutput()">✕ Clear</button>
98
+ </div>
99
+ </div>
100
+
101
+ <script>
102
+ let currentAbort = null;
103
+ let allTools = [];
104
+ let allAgents = [];
105
+
106
+ async function init() {
107
+ try {
108
+ const [tools, agents] = await Promise.all([
109
+ fetch('/tools').then(r => r.json()),
110
+ fetch('/agents').then(r => r.json()),
111
+ ]);
112
+ allTools = tools;
113
+ allAgents = agents;
114
+
115
+ const toolSel = document.getElementById('tool-select');
116
+ toolSel.innerHTML = '<option value="">— select —</option>' + tools.map(t =>
117
+ `<option value="${t.name}">${t.display_name}</option>`
118
+ ).join('');
119
+
120
+ const agentSel = document.getElementById('agent-select');
121
+ agentSel.innerHTML = '<option value="">— select —</option>' + agents.map(a =>
122
+ `<option value="${a.name}">${a.display_name}</option>`
123
+ ).join('');
124
+
125
+ const params = new URLSearchParams(location.search);
126
+ if (params.get('tab') === 'agent') switchTab('agent');
127
+ if (params.get('tool')) { toolSel.value = params.get('tool'); onToolChange(); }
128
+ if (params.get('agent')) { agentSel.value = params.get('agent'); switchTab('agent'); onAgentChange(); }
129
+ } catch(e) {
130
+ appendLine('error', 'Failed to load tools/agents: ' + e.message);
131
+ }
132
+ }
133
+
134
+ function switchTab(tab) {
135
+ document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
136
+ document.getElementById('panel-tool').classList.toggle('hidden', tab !== 'tool');
137
+ document.getElementById('panel-agent').classList.toggle('hidden', tab !== 'agent');
138
+ }
139
+
140
+ function onToolChange() {
141
+ const sel = document.getElementById('tool-select');
142
+ const tool = allTools.find(t => t.name === sel.value);
143
+ renderParams('tool-params', tool ? tool.parameters : null);
144
+ }
145
+
146
+ function onAgentChange() {
147
+ const sel = document.getElementById('agent-select');
148
+ const agent = allAgents.find(a => a.name === sel.value);
149
+ const area = document.getElementById('agent-params');
150
+ if (!agent) { area.innerHTML = ''; return; }
151
+ area.innerHTML = `
152
+ <div class="param-row">
153
+ <label>User Input <span class="param-type">string</span></label>
154
+ <textarea rows="3" id="agent-text" placeholder="Enter your message for the agent...">${agent.description ? 'Tell me about ' + agent.name : 'Hello'}</textarea>
155
+ </div>
156
+ <div class="param-row">
157
+ <label>System Prompt (optional) <span class="param-type">string</span></label>
158
+ <textarea rows="2" id="agent-system-prompt" placeholder="Leave empty to use default"></textarea>
159
+ </div>
160
+ `;
161
+ }
162
+
163
+ function renderParams(containerId, params) {
164
+ const area = document.getElementById(containerId);
165
+ if (!params || !params.properties) { area.innerHTML = ''; return; }
166
+ const required = new Set(params.required || []);
167
+ area.innerHTML = Object.entries(params.properties).map(([key, prop]) => {
168
+ const isObj = prop.type === 'object' || prop.type === 'array'
169
+ || (prop.anyOf && prop.anyOf.some(s => s.type === 'object' || s.type === 'array'))
170
+ || prop.additionalProperties !== undefined;
171
+ const isEnum = prop.enum && prop.enum.length > 0;
172
+ const req = required.has(key) ? ' *' : '';
173
+ const desc = prop.description ? `<div class="param-desc">${prop.description}</div>` : '';
174
+ const typeInfo = (prop.enum ? prop.enum.join(' | ') : prop.type)
175
+ || (isObj ? 'object' : 'string');
176
+
177
+ let input = '';
178
+ if (isObj) {
179
+ const isArray = prop.type === 'array'
180
+ || (prop.anyOf && prop.anyOf.some(s => s.type === 'array'));
181
+ input = `<textarea rows="3" id="param-${key}" placeholder='${isArray ? '["item1","item2"]' : '{"key":"value"}'}'>${prop.default ? JSON.stringify(prop.default, null, 2) : ''}</textarea>`;
182
+ } else if (isEnum) {
183
+ const opts = prop.enum.map(v => `<option value="${v}"${prop.default === v ? ' selected' : ''}>${v}</option>`).join('');
184
+ input = `<select id="param-${key}"><option value="">— select —</option>${opts}</select>`;
185
+ } else {
186
+ input = `<input type="text" id="param-${key}" placeholder="${prop.type || 'value'}" value="${prop.default || ''}">`;
187
+ }
188
+
189
+ return `<div class="param-row">
190
+ <label>${key}${req} <span class="param-type">${typeInfo}</span></label>
191
+ ${input}
192
+ ${desc}
193
+ </div>`;
194
+ }).join('');
195
+ }
196
+
197
+ function collectParams(containerId) {
198
+ const area = document.getElementById(containerId);
199
+ const inputs = area.querySelectorAll('[id^="param-"]');
200
+ const args = {};
201
+ inputs.forEach(el => {
202
+ const key = el.id.replace('param-', '');
203
+ let val = el.value;
204
+ if (el.tagName === 'TEXTAREA') {
205
+ try { val = JSON.parse(val); } catch(e) {}
206
+ }
207
+ args[key] = val;
208
+ });
209
+ return args;
210
+ }
211
+
212
+ function isActiveTab(tab) {
213
+ return document.querySelector('.tab.active').dataset.tab === tab;
214
+ }
215
+
216
+ function getActiveEndpoint() {
217
+ if (isActiveTab('tool')) {
218
+ const sel = document.getElementById('tool-select');
219
+ return sel.value ? { type: 'tool', name: sel.value } : null;
220
+ }
221
+ const sel = document.getElementById('agent-select');
222
+ return sel.value ? { type: 'agent', name: sel.value } : null;
223
+ }
224
+
225
+ async function invoke() {
226
+ const ep = getActiveEndpoint();
227
+ if (!ep) { appendLine('error', 'Please select an endpoint first'); return; }
228
+
229
+ clearOutput();
230
+ setRunning(true);
231
+
232
+ let url, body;
233
+ if (ep.type === 'tool') {
234
+ url = '/tools/' + encodeURIComponent(ep.name) + '/execute';
235
+ body = { args: collectParams('tool-params') };
236
+ } else {
237
+ url = '/agent/' + encodeURIComponent(ep.name) + '/run';
238
+ const text = document.getElementById('agent-text')?.value || '';
239
+ const sysPrompt = document.getElementById('agent-system-prompt')?.value || '';
240
+ body = { user_input: [{ type: 'text', text }] };
241
+ if (sysPrompt) body.system_prompt = sysPrompt;
242
+ }
243
+
244
+ currentAbort = new AbortController();
245
+ try {
246
+ const resp = await fetch(url, {
247
+ method: 'POST',
248
+ headers: { 'Content-Type': 'application/json' },
249
+ body: JSON.stringify(body),
250
+ signal: currentAbort.signal,
251
+ });
252
+
253
+ if (!resp.ok) {
254
+ appendLine('error', `HTTP ${resp.status}: ${resp.statusText}`);
255
+ setRunning(false, 'error');
256
+ return;
257
+ }
258
+
259
+ const reader = resp.body.getReader();
260
+ const decoder = new TextDecoder();
261
+ let buffer = '';
262
+
263
+ while (true) {
264
+ const { done, value } = await reader.read();
265
+ if (done) break;
266
+ buffer += decoder.decode(value, { stream: true });
267
+ const lines = buffer.split('\n');
268
+ buffer = lines.pop() || '';
269
+ for (const line of lines) {
270
+ if (line.startsWith('data: ')) {
271
+ try {
272
+ const evt = JSON.parse(line.slice(6));
273
+ appendEvent(evt);
274
+ } catch { /* skip malformed */ }
275
+ }
276
+ }
277
+ }
278
+ setRunning(false, 'done');
279
+ } catch (e) {
280
+ if (e.name === 'AbortError') {
281
+ appendLine('info', '— Cancelled —');
282
+ } else {
283
+ appendLine('error', 'Error: ' + e.message);
284
+ }
285
+ setRunning(false, 'error');
286
+ }
287
+ currentAbort = null;
288
+ }
289
+
290
+ function cancelInvoke() {
291
+ if (currentAbort) {
292
+ currentAbort.abort();
293
+ currentAbort = null;
294
+ }
295
+ }
296
+
297
+ function appendEvent(evt) {
298
+ const t = evt.type || '';
299
+ let tagClass = 'tag-info';
300
+ if (t === 'tool_start' || t === 'agent_start' || t === 'execution_start') tagClass = 'tag-start';
301
+ else if (t === 'tool_progress' || t === 'llm_chunk') tagClass = 'tag-progress';
302
+ else if (t === 'tool_end' || t === 'execution_end' || t === 'agent_end') tagClass = 'tag-end';
303
+ else if (t.includes('error') || t.includes('Error')) tagClass = 'tag-error';
304
+ else if (t.startsWith('llm_')) tagClass = 'tag-llm';
305
+
306
+ const content = JSON.stringify(evt, null, 2);
307
+ appendLine(tagClass, `<span class="tag ${tagClass}">${t}</span>${escapeHtml(content)}`);
308
+ }
309
+
310
+ function appendLine(tag, html) {
311
+ const out = document.getElementById('output');
312
+ const placeholder = out.querySelector('.output-placeholder');
313
+ if (placeholder) placeholder.remove();
314
+ const div = document.createElement('div');
315
+ div.className = 'line';
316
+ div.innerHTML = html;
317
+ out.appendChild(div);
318
+ out.scrollTop = out.scrollHeight;
319
+ }
320
+
321
+ function escapeHtml(s) {
322
+ const d = document.createElement('div');
323
+ d.textContent = s;
324
+ return d.innerHTML;
325
+ }
326
+
327
+ function clearOutput() {
328
+ document.getElementById('output').innerHTML = '<span class="output-placeholder">Select an endpoint and click Invoke to see results…</span>';
329
+ }
330
+
331
+ function setRunning(running, status) {
332
+ const btn = document.getElementById('invoke-btn');
333
+ const cancelBtn = document.getElementById('cancel-btn');
334
+ const badge = document.getElementById('status-badge');
335
+ btn.disabled = running;
336
+ btn.classList.toggle('hidden', running);
337
+ cancelBtn.classList.toggle('hidden', !running);
338
+ badge.classList.toggle('hidden', !running && !status);
339
+ badge.classList.remove('running', 'done', 'error');
340
+ if (running) {
341
+ badge.textContent = 'Running…';
342
+ badge.classList.add('running');
343
+ } else if (status) {
344
+ const labels = { done: 'Done', error: 'Error' };
345
+ badge.textContent = labels[status] || status;
346
+ badge.classList.add(status);
347
+ }
348
+ }
349
+
350
+ document.addEventListener('DOMContentLoaded', init);
351
+ </script>
352
+ </body>
353
+ </html>"""
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+
7
+ def sse_line(event: str, data: Any) -> str:
8
+ return f"data: {json.dumps({'type': event, 'data': data}, ensure_ascii=False, default=str)}\n\n"