create-windy 0.2.20 → 0.2.21
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.
- package/README.md +1 -1
- package/dist/cli.js +84 -10
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/README.md +1 -0
- package/template/apps/agent-server/Dockerfile +20 -0
- package/template/apps/agent-server/README.md +46 -0
- package/template/apps/agent-server/pyproject.toml +44 -0
- package/template/apps/agent-server/src/southwind_agent_server/__init__.py +5 -0
- package/template/apps/agent-server/src/southwind_agent_server/adk_adapter.py +39 -0
- package/template/apps/agent-server/src/southwind_agent_server/app.py +263 -0
- package/template/apps/agent-server/src/southwind_agent_server/config.py +141 -0
- package/template/apps/agent-server/src/southwind_agent_server/contracts.py +99 -0
- package/template/apps/agent-server/src/southwind_agent_server/deterministic.py +52 -0
- package/template/apps/agent-server/src/southwind_agent_server/engine.py +85 -0
- package/template/apps/agent-server/src/southwind_agent_server/openai_engine.py +197 -0
- package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +246 -0
- package/template/apps/agent-server/src/southwind_agent_server/service.py +180 -0
- package/template/apps/agent-server/src/southwind_agent_server/sqlite_store.py +317 -0
- package/template/apps/agent-server/src/southwind_agent_server/sse.py +41 -0
- package/template/apps/agent-server/src/southwind_agent_server/store.py +144 -0
- package/template/apps/agent-server/src/southwind_agent_server/tool_host.py +80 -0
- package/template/apps/agent-server/tests/test_api.py +207 -0
- package/template/apps/agent-server/tests/test_config.py +56 -0
- package/template/apps/agent-server/tests/test_openai_provider.py +224 -0
- package/template/apps/agent-server/tests/test_sqlite_store.py +93 -0
- package/template/apps/agent-server/uv.lock +925 -0
- package/template/apps/server/src/agent/execution-grants.ts +89 -0
- package/template/apps/server/src/agent/remote-runtime.ts +128 -0
- package/template/apps/server/src/agent/run-contracts.ts +43 -0
- package/template/apps/server/src/agent/run-facade.test.ts +257 -0
- package/template/apps/server/src/agent/run-facade.ts +190 -0
- package/template/apps/server/src/agent/run-routes.ts +222 -0
- package/template/apps/server/src/agent/runtime-bootstrap.ts +53 -0
- package/template/apps/server/src/agent/tool-host.test.ts +242 -0
- package/template/apps/server/src/agent/tool-host.ts +153 -0
- package/template/apps/server/src/index.ts +12 -16
- package/template/apps/server/src/module-composition.test.ts +9 -0
- package/template/apps/server/src/module-composition.ts +13 -0
- package/template/apps/server/src/module-host/initialize-contracts.ts +67 -0
- package/template/apps/server/src/module-host/initialize.test.ts +69 -0
- package/template/apps/server/src/module-host/initialize.ts +50 -53
- package/template/apps/server/src/module-host.ts +1 -0
- package/template/apps/server/src/module-runtime-validation.ts +40 -0
- package/template/docker-compose.yml +25 -0
- package/template/docs/architecture/ai-runtime.md +744 -0
- package/template/docs/platform/agent-runtime.md +128 -0
- package/template/docs/platform/agent-tools.md +8 -1
- package/template/package.json +1 -0
- package/template/packages/config/index.test.ts +43 -0
- package/template/packages/config/index.ts +1 -0
- package/template/packages/config/package.json +2 -1
- package/template/packages/config/src/ai-openai-compatible.ts +131 -0
- package/template/packages/server-sdk/index.ts +11 -0
- package/template/packages/server-sdk/package.json +4 -2
- package/template/packages/server-sdk/src/ai-operation-registration.ts +31 -0
- package/template/packages/server-sdk/src/module-host.ts +5 -0
- package/template/packages/server-sdk/src/tool-host-port.ts +22 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from typing import cast
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pytest
|
|
10
|
+
from fastapi import FastAPI
|
|
11
|
+
|
|
12
|
+
from southwind_agent_server.app import create_app
|
|
13
|
+
from southwind_agent_server.deterministic import DeterministicEngineAdapter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def app() -> FastAPI:
|
|
18
|
+
return create_app(DeterministicEngineAdapter(), heartbeat_seconds=0.01)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
async def client(app: FastAPI) -> AsyncIterator[httpx.AsyncClient]:
|
|
23
|
+
async with httpx.AsyncClient(
|
|
24
|
+
transport=httpx.ASGITransport(app=app),
|
|
25
|
+
base_url="http://agent.test",
|
|
26
|
+
) as value:
|
|
27
|
+
yield value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def start(
|
|
31
|
+
client: httpx.AsyncClient,
|
|
32
|
+
operation: str,
|
|
33
|
+
input_value: object,
|
|
34
|
+
key: str,
|
|
35
|
+
) -> dict[str, object]:
|
|
36
|
+
response = await client.post(
|
|
37
|
+
"/api/agent/runs",
|
|
38
|
+
headers={"Idempotency-Key": key},
|
|
39
|
+
json={"operationKey": operation, "input": input_value},
|
|
40
|
+
)
|
|
41
|
+
assert response.status_code == 200
|
|
42
|
+
return cast(dict[str, object], response.json())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def events(response: httpx.Response) -> list[dict[str, object]]:
|
|
46
|
+
result: list[dict[str, object]] = []
|
|
47
|
+
for frame in response.text.split("\n\n"):
|
|
48
|
+
data = next(
|
|
49
|
+
(
|
|
50
|
+
line.removeprefix("data: ")
|
|
51
|
+
for line in frame.splitlines()
|
|
52
|
+
if line.startswith("data: ")
|
|
53
|
+
),
|
|
54
|
+
None,
|
|
55
|
+
)
|
|
56
|
+
if data is not None:
|
|
57
|
+
result.append(json.loads(data))
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def wait_for_terminal(
|
|
62
|
+
client: httpx.AsyncClient,
|
|
63
|
+
run_id: str,
|
|
64
|
+
) -> list[dict[str, object]]:
|
|
65
|
+
response = await client.get(f"/api/agent/runs/{run_id}/events")
|
|
66
|
+
assert response.status_code == 200
|
|
67
|
+
return events(response)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def test_result_and_canonical_sequence(client: httpx.AsyncClient) -> None:
|
|
71
|
+
created = await start(client, "test.echo", {"text": "你好"}, "echo-1")
|
|
72
|
+
run_id = str(created["runId"])
|
|
73
|
+
emitted = await wait_for_terminal(client, run_id)
|
|
74
|
+
|
|
75
|
+
assert [event["sequence"] for event in emitted] == [1, 2, 3, 4]
|
|
76
|
+
assert [event["type"] for event in emitted] == [
|
|
77
|
+
"run.started",
|
|
78
|
+
"output.delta",
|
|
79
|
+
"usage.reported",
|
|
80
|
+
"run.completed",
|
|
81
|
+
]
|
|
82
|
+
result = await client.get(f"/api/agent/runs/{run_id}/result")
|
|
83
|
+
assert result.json() == {
|
|
84
|
+
"runId": run_id,
|
|
85
|
+
"status": "completed",
|
|
86
|
+
"output": {"text": "你好"},
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def test_replay_supports_header_and_after(client: httpx.AsyncClient) -> None:
|
|
91
|
+
created = await start(client, "test.echo", {"text": "replay"}, "replay-1")
|
|
92
|
+
run_id = str(created["runId"])
|
|
93
|
+
await wait_for_terminal(client, run_id)
|
|
94
|
+
|
|
95
|
+
from_header = await client.get(
|
|
96
|
+
f"/api/agent/runs/{run_id}/events",
|
|
97
|
+
headers={"Last-Event-ID": "2"},
|
|
98
|
+
)
|
|
99
|
+
from_query = await client.get(f"/api/agent/runs/{run_id}/events?after=3")
|
|
100
|
+
assert [event["sequence"] for event in events(from_header)] == [3, 4]
|
|
101
|
+
assert [event["sequence"] for event in events(from_query)] == [4]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
async def test_idempotency_reuses_or_conflicts(client: httpx.AsyncClient) -> None:
|
|
105
|
+
first = await start(client, "test.echo", {"text": "same"}, "same-key")
|
|
106
|
+
second = await start(client, "test.echo", {"text": "same"}, "same-key")
|
|
107
|
+
assert second["runId"] == first["runId"]
|
|
108
|
+
|
|
109
|
+
conflict = await client.post(
|
|
110
|
+
"/api/agent/runs",
|
|
111
|
+
headers={"Idempotency-Key": "same-key"},
|
|
112
|
+
json={"operationKey": "test.echo", "input": {"text": "different"}},
|
|
113
|
+
)
|
|
114
|
+
assert conflict.status_code == 409
|
|
115
|
+
assert conflict.json()["code"] == "RUN_CONFLICT"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def test_cancel_emits_one_terminal_and_heartbeat(
|
|
119
|
+
client: httpx.AsyncClient,
|
|
120
|
+
) -> None:
|
|
121
|
+
created = await start(client, "test.wait", None, "cancel-1")
|
|
122
|
+
run_id = str(created["runId"])
|
|
123
|
+
stream = asyncio.create_task(client.get(f"/api/agent/runs/{run_id}/events"))
|
|
124
|
+
await asyncio.sleep(0.03)
|
|
125
|
+
cancelled = await client.post(
|
|
126
|
+
f"/api/agent/runs/{run_id}/cancel",
|
|
127
|
+
json={"reason": "user request"},
|
|
128
|
+
)
|
|
129
|
+
response = await stream
|
|
130
|
+
emitted = events(response)
|
|
131
|
+
|
|
132
|
+
assert cancelled.json()["status"] == "cancelled"
|
|
133
|
+
assert ": heartbeat" in response.text
|
|
134
|
+
assert [event["type"] for event in emitted].count("run.cancelled") == 1
|
|
135
|
+
assert emitted[-1]["type"] == "run.cancelled"
|
|
136
|
+
result = await client.get(f"/api/agent/runs/{run_id}/result")
|
|
137
|
+
assert result.status_code == 409
|
|
138
|
+
assert result.json()["code"] == "RUN_CANCELLED"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
async def test_failure_never_leaks_upstream_exception(
|
|
142
|
+
client: httpx.AsyncClient,
|
|
143
|
+
) -> None:
|
|
144
|
+
created = await start(client, "test.fail", None, "fail-1")
|
|
145
|
+
run_id = str(created["runId"])
|
|
146
|
+
emitted = await wait_for_terminal(client, run_id)
|
|
147
|
+
terminal = emitted[-1]
|
|
148
|
+
|
|
149
|
+
assert terminal["type"] == "run.failed"
|
|
150
|
+
serialized = json.dumps(terminal, ensure_ascii=False)
|
|
151
|
+
assert "upstream-secret-body" not in serialized
|
|
152
|
+
error = cast(dict[str, object], terminal["error"])
|
|
153
|
+
assert error["code"] == "RUNTIME_UNAVAILABLE"
|
|
154
|
+
result = await client.get(f"/api/agent/runs/{run_id}/result")
|
|
155
|
+
assert "upstream-secret-body" not in result.text
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def test_request_validation_uses_canonical_safe_error(
|
|
159
|
+
client: httpx.AsyncClient,
|
|
160
|
+
) -> None:
|
|
161
|
+
response = await client.post(
|
|
162
|
+
"/api/agent/runs",
|
|
163
|
+
json={"operationKey": "", "input": None},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
assert response.status_code == 400
|
|
167
|
+
assert response.json()["code"] == "INVALID_INPUT"
|
|
168
|
+
assert "detail" not in response.json()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def test_internal_token_protects_run_api() -> None:
|
|
172
|
+
protected = create_app(
|
|
173
|
+
DeterministicEngineAdapter(),
|
|
174
|
+
internal_token="internal-secret",
|
|
175
|
+
)
|
|
176
|
+
async with httpx.AsyncClient(
|
|
177
|
+
transport=httpx.ASGITransport(app=protected),
|
|
178
|
+
base_url="http://agent.test",
|
|
179
|
+
) as client:
|
|
180
|
+
denied = await client.post(
|
|
181
|
+
"/api/agent/runs",
|
|
182
|
+
headers={"Idempotency-Key": "protected-1"},
|
|
183
|
+
json={"operationKey": "test.echo", "input": None},
|
|
184
|
+
)
|
|
185
|
+
allowed = await client.post(
|
|
186
|
+
"/api/agent/runs",
|
|
187
|
+
headers={
|
|
188
|
+
"Idempotency-Key": "protected-1",
|
|
189
|
+
"Authorization": "Bearer internal-secret",
|
|
190
|
+
},
|
|
191
|
+
json={"operationKey": "test.echo", "input": None},
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
assert denied.status_code == 403
|
|
195
|
+
assert denied.json()["code"] == "FORBIDDEN"
|
|
196
|
+
assert allowed.status_code == 200
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
async def test_liveness_does_not_claim_provider_readiness(
|
|
200
|
+
client: httpx.AsyncClient,
|
|
201
|
+
) -> None:
|
|
202
|
+
live = await client.get("/health/live")
|
|
203
|
+
ready = await client.get("/health/ready")
|
|
204
|
+
|
|
205
|
+
assert live.json() == {"status": "live"}
|
|
206
|
+
assert ready.status_code == 503
|
|
207
|
+
assert ready.json()["code"] == "RUNTIME_UNAVAILABLE"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from southwind_agent_server.config import (
|
|
6
|
+
EnvironmentSecretResolver,
|
|
7
|
+
OpenAiCompatibleConfig,
|
|
8
|
+
normalize_base_url,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_server_config_uses_secret_ref_and_normalizes_endpoint() -> None:
|
|
13
|
+
environment = {
|
|
14
|
+
"QWEN_API_URL": "https://example.com/compatible-mode/v1/",
|
|
15
|
+
"QWEN_MODEL": "qwen-plus",
|
|
16
|
+
"QWEN_API_KEY": "server-secret",
|
|
17
|
+
}
|
|
18
|
+
config = OpenAiCompatibleConfig.from_environment(environment)
|
|
19
|
+
assert config is not None
|
|
20
|
+
assert config.base_url == "https://example.com/compatible-mode/v1"
|
|
21
|
+
assert str(config.api_key_ref) == "[SecretRef]"
|
|
22
|
+
resolver = EnvironmentSecretResolver(environment)
|
|
23
|
+
assert config.api_key_ref is not None
|
|
24
|
+
assert resolver.resolve(config.api_key_ref) == "server-secret"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.parametrize(
|
|
28
|
+
"endpoint",
|
|
29
|
+
[
|
|
30
|
+
"http://public.example.com/v1",
|
|
31
|
+
"http://169.254.169.254/v1",
|
|
32
|
+
"https://user:pass@example.com/v1",
|
|
33
|
+
"https://example.com/v1?token=secret",
|
|
34
|
+
],
|
|
35
|
+
)
|
|
36
|
+
def test_rejects_unsafe_endpoint(endpoint: str) -> None:
|
|
37
|
+
with pytest.raises(ValueError):
|
|
38
|
+
normalize_base_url(endpoint)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_allows_private_http_endpoint() -> None:
|
|
42
|
+
assert normalize_base_url("http://qwen.internal:9000/v1/") == (
|
|
43
|
+
"http://qwen.internal:9000/v1"
|
|
44
|
+
)
|
|
45
|
+
assert normalize_base_url("http://10.20.30.40:8000/v1") == (
|
|
46
|
+
"http://10.20.30.40:8000/v1"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_partial_deployment_fails_fast_without_leaking_value() -> None:
|
|
51
|
+
marker = "DO-NOT-LEAK"
|
|
52
|
+
with pytest.raises(ValueError) as captured:
|
|
53
|
+
OpenAiCompatibleConfig.from_environment(
|
|
54
|
+
{"QWEN_API_URL": marker, "QWEN_MODEL": ""}
|
|
55
|
+
)
|
|
56
|
+
assert marker not in str(captured.value)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from southwind_agent_server.config import (
|
|
9
|
+
EnvironmentSecretResolver,
|
|
10
|
+
OpenAiCompatibleConfig,
|
|
11
|
+
SecretRef,
|
|
12
|
+
)
|
|
13
|
+
from southwind_agent_server.contracts import RuntimeLimits, RuntimeToolDefinition
|
|
14
|
+
from southwind_agent_server.engine import (
|
|
15
|
+
EngineRunRequest,
|
|
16
|
+
SafeEngineError,
|
|
17
|
+
ToolCompleted,
|
|
18
|
+
ToolRequested,
|
|
19
|
+
)
|
|
20
|
+
from southwind_agent_server.openai_engine import OpenAiCompatibleEngineAdapter
|
|
21
|
+
from southwind_agent_server.openai_provider import OpenAiCompatibleProvider
|
|
22
|
+
from southwind_agent_server.tool_host import ToolHostClient
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def test_provider_stream_and_health_use_bounded_safe_protocol() -> None:
|
|
26
|
+
async def handler(request: httpx.Request) -> httpx.Response:
|
|
27
|
+
if request.url.path.endswith("/models"):
|
|
28
|
+
return httpx.Response(200, json={"data": []})
|
|
29
|
+
return sse_response(
|
|
30
|
+
[
|
|
31
|
+
{"choices": [{"delta": {"content": "你"}}]},
|
|
32
|
+
{
|
|
33
|
+
"choices": [{"delta": {"content": "好"}}],
|
|
34
|
+
"usage": {
|
|
35
|
+
"prompt_tokens": 2,
|
|
36
|
+
"completion_tokens": 2,
|
|
37
|
+
"total_tokens": 4,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
provider = create_provider(httpx.MockTransport(handler))
|
|
44
|
+
await provider.health()
|
|
45
|
+
turn = await provider.complete_turn(
|
|
46
|
+
[{"role": "user", "content": "你好"}],
|
|
47
|
+
(),
|
|
48
|
+
)
|
|
49
|
+
assert turn.text_deltas == ["你", "好"]
|
|
50
|
+
assert turn.usage == {
|
|
51
|
+
"source": "provider-reported",
|
|
52
|
+
"inputTokens": 2,
|
|
53
|
+
"outputTokens": 2,
|
|
54
|
+
"totalTokens": 4,
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def test_provider_error_never_leaks_body_or_secret() -> None:
|
|
59
|
+
marker = "upstream-secret-body"
|
|
60
|
+
|
|
61
|
+
async def handler(_request: httpx.Request) -> httpx.Response:
|
|
62
|
+
return httpx.Response(500, text=marker)
|
|
63
|
+
|
|
64
|
+
provider = create_provider(httpx.MockTransport(handler))
|
|
65
|
+
with pytest.raises(SafeEngineError) as captured:
|
|
66
|
+
await provider.complete_turn([], ())
|
|
67
|
+
assert captured.value.code == "PROVIDER_UNAVAILABLE"
|
|
68
|
+
assert marker not in str(captured.value)
|
|
69
|
+
assert "server-secret" not in str(captured.value)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def test_engine_executes_only_allowed_tool_through_host() -> None:
|
|
73
|
+
provider_calls = 0
|
|
74
|
+
tool_requests: list[dict[str, object]] = []
|
|
75
|
+
|
|
76
|
+
async def provider_handler(request: httpx.Request) -> httpx.Response:
|
|
77
|
+
nonlocal provider_calls
|
|
78
|
+
provider_calls += 1
|
|
79
|
+
if provider_calls == 1:
|
|
80
|
+
return sse_response(
|
|
81
|
+
[
|
|
82
|
+
{
|
|
83
|
+
"choices": [
|
|
84
|
+
{
|
|
85
|
+
"delta": {
|
|
86
|
+
"tool_calls": [
|
|
87
|
+
{
|
|
88
|
+
"index": 0,
|
|
89
|
+
"id": "call_1",
|
|
90
|
+
"function": {
|
|
91
|
+
"name": "business.records.list",
|
|
92
|
+
"arguments": '{"page":2}',
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
]
|
|
99
|
+
}
|
|
100
|
+
]
|
|
101
|
+
)
|
|
102
|
+
return sse_response([{"choices": [{"delta": {"content": "共有一条记录"}}]}])
|
|
103
|
+
|
|
104
|
+
async def tool_handler(request: httpx.Request) -> httpx.Response:
|
|
105
|
+
assert request.headers["authorization"] == "Bearer internal-token"
|
|
106
|
+
tool_requests.append(json.loads(request.content))
|
|
107
|
+
return httpx.Response(200, json={"value": {"total": 1}})
|
|
108
|
+
|
|
109
|
+
engine = OpenAiCompatibleEngineAdapter(
|
|
110
|
+
create_provider(httpx.MockTransport(provider_handler)),
|
|
111
|
+
ToolHostClient(
|
|
112
|
+
"internal-token",
|
|
113
|
+
transport=httpx.MockTransport(tool_handler),
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
signals = [
|
|
117
|
+
signal
|
|
118
|
+
async for signal in engine.execute(runtime_request("business.records.list"))
|
|
119
|
+
]
|
|
120
|
+
assert any(isinstance(signal, ToolRequested) for signal in signals)
|
|
121
|
+
assert any(isinstance(signal, ToolCompleted) for signal in signals)
|
|
122
|
+
assert tool_requests[0] == {
|
|
123
|
+
"executionGrant": "grant_1",
|
|
124
|
+
"runId": "run_1",
|
|
125
|
+
"toolCallId": "call_1",
|
|
126
|
+
"toolKey": "business.records.list",
|
|
127
|
+
"arguments": {"page": 2},
|
|
128
|
+
}
|
|
129
|
+
assert signals[-1].output == {"text": "共有一条记录"} # type: ignore[union-attr]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def test_engine_denies_unknown_model_tool_before_host_callback() -> None:
|
|
133
|
+
tool_called = False
|
|
134
|
+
|
|
135
|
+
async def provider_handler(_request: httpx.Request) -> httpx.Response:
|
|
136
|
+
return sse_response(
|
|
137
|
+
[
|
|
138
|
+
{
|
|
139
|
+
"choices": [
|
|
140
|
+
{
|
|
141
|
+
"delta": {
|
|
142
|
+
"tool_calls": [
|
|
143
|
+
{
|
|
144
|
+
"index": 0,
|
|
145
|
+
"id": "call_bad",
|
|
146
|
+
"function": {
|
|
147
|
+
"name": "business.admin.delete",
|
|
148
|
+
"arguments": "{}",
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
]
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
]
|
|
155
|
+
}
|
|
156
|
+
]
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
async def tool_handler(_request: httpx.Request) -> httpx.Response:
|
|
160
|
+
nonlocal tool_called
|
|
161
|
+
tool_called = True
|
|
162
|
+
return httpx.Response(200, json={"value": None})
|
|
163
|
+
|
|
164
|
+
engine = OpenAiCompatibleEngineAdapter(
|
|
165
|
+
create_provider(httpx.MockTransport(provider_handler)),
|
|
166
|
+
ToolHostClient(
|
|
167
|
+
"internal-token",
|
|
168
|
+
transport=httpx.MockTransport(tool_handler),
|
|
169
|
+
),
|
|
170
|
+
)
|
|
171
|
+
with pytest.raises(SafeEngineError) as captured:
|
|
172
|
+
async for _signal in engine.execute(runtime_request("business.records.list")):
|
|
173
|
+
pass
|
|
174
|
+
assert captured.value.code == "TOOL_DENIED"
|
|
175
|
+
assert tool_called is False
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def create_provider(
|
|
179
|
+
transport: httpx.AsyncBaseTransport,
|
|
180
|
+
) -> OpenAiCompatibleProvider:
|
|
181
|
+
environment = {"QWEN_API_KEY": "server-secret"}
|
|
182
|
+
return OpenAiCompatibleProvider(
|
|
183
|
+
OpenAiCompatibleConfig(
|
|
184
|
+
base_url="https://provider.test/v1",
|
|
185
|
+
model="qwen-compatible",
|
|
186
|
+
api_key_ref=SecretRef("env", "QWEN_API_KEY"),
|
|
187
|
+
),
|
|
188
|
+
EnvironmentSecretResolver(environment),
|
|
189
|
+
transport=transport,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def runtime_request(tool_key: str) -> EngineRunRequest:
|
|
194
|
+
return EngineRunRequest(
|
|
195
|
+
run_id="run_1",
|
|
196
|
+
operation_key="business.answer",
|
|
197
|
+
input={"question": "记录"},
|
|
198
|
+
instructions="回答业务问题。",
|
|
199
|
+
user_prompt="有多少记录?",
|
|
200
|
+
allowed_tools=(
|
|
201
|
+
RuntimeToolDefinition(
|
|
202
|
+
key=tool_key,
|
|
203
|
+
label="查询记录",
|
|
204
|
+
description="查询当前数据范围内的记录。",
|
|
205
|
+
),
|
|
206
|
+
),
|
|
207
|
+
limits=RuntimeLimits(
|
|
208
|
+
max_duration_seconds=60,
|
|
209
|
+
max_output_bytes=4096,
|
|
210
|
+
),
|
|
211
|
+
execution_grant="grant_1",
|
|
212
|
+
tool_host_url="http://server:9747",
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def sse_response(chunks: list[object]) -> httpx.Response:
|
|
217
|
+
body = "".join(
|
|
218
|
+
f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n" for chunk in chunks
|
|
219
|
+
)
|
|
220
|
+
return httpx.Response(
|
|
221
|
+
200,
|
|
222
|
+
headers={"content-type": "text/event-stream"},
|
|
223
|
+
content=f"{body}data: [DONE]\n\n",
|
|
224
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import cast
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from southwind_agent_server.sqlite_store import (
|
|
9
|
+
RunStoreCapacityError,
|
|
10
|
+
SqliteRunStore,
|
|
11
|
+
)
|
|
12
|
+
from southwind_agent_server.store import RunRecord
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def test_terminal_run_and_cursor_survive_store_rebuild(
|
|
16
|
+
tmp_path: Path,
|
|
17
|
+
) -> None:
|
|
18
|
+
path = str(tmp_path / "runs.sqlite3")
|
|
19
|
+
first = SqliteRunStore(path)
|
|
20
|
+
record = fixture("run_1", "key_1")
|
|
21
|
+
await first.create_or_get(record)
|
|
22
|
+
await first.append("run_1", "run.started")
|
|
23
|
+
await first.append("run_1", "output.delta", {"text": "你好"})
|
|
24
|
+
await first.complete("run_1", {"text": "你好"})
|
|
25
|
+
first.close()
|
|
26
|
+
|
|
27
|
+
restored = SqliteRunStore(path)
|
|
28
|
+
current = await restored.get("run_1")
|
|
29
|
+
events, terminal = await restored.events_after("run_1", 1, 0.01)
|
|
30
|
+
restored.close()
|
|
31
|
+
|
|
32
|
+
assert current is not None
|
|
33
|
+
assert current.status == "completed"
|
|
34
|
+
assert current.output == {"text": "你好"}
|
|
35
|
+
assert [event["sequence"] for event in events] == [2, 3]
|
|
36
|
+
assert terminal is True
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def test_incomplete_run_recovers_as_one_safe_terminal(
|
|
40
|
+
tmp_path: Path,
|
|
41
|
+
) -> None:
|
|
42
|
+
path = str(tmp_path / "runs.sqlite3")
|
|
43
|
+
first = SqliteRunStore(path)
|
|
44
|
+
await first.create_or_get(fixture("run_2", "key_2"))
|
|
45
|
+
await first.append("run_2", "run.started")
|
|
46
|
+
first.close()
|
|
47
|
+
|
|
48
|
+
restored = SqliteRunStore(path)
|
|
49
|
+
current = await restored.get("run_2")
|
|
50
|
+
restored.close()
|
|
51
|
+
|
|
52
|
+
assert current is not None
|
|
53
|
+
assert current.status == "failed"
|
|
54
|
+
assert [event["type"] for event in current.events] == [
|
|
55
|
+
"run.started",
|
|
56
|
+
"run.failed",
|
|
57
|
+
]
|
|
58
|
+
error = cast(dict[str, object], current.events[-1]["error"])
|
|
59
|
+
assert error["code"] == "RUNTIME_UNAVAILABLE"
|
|
60
|
+
assert error["retryable"] is True
|
|
61
|
+
assert "secret" not in str(current.events)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def test_event_and_run_capacity_are_bounded(tmp_path: Path) -> None:
|
|
65
|
+
store = SqliteRunStore(
|
|
66
|
+
str(tmp_path / "bounded.sqlite3"),
|
|
67
|
+
max_runs=1,
|
|
68
|
+
max_events_per_run=3,
|
|
69
|
+
)
|
|
70
|
+
await store.create_or_get(fixture("run_3", "key_3"))
|
|
71
|
+
await store.append("run_3", "run.started")
|
|
72
|
+
await store.append("run_3", "output.delta", {"text": "one"})
|
|
73
|
+
with pytest.raises(RunStoreCapacityError):
|
|
74
|
+
await store.append("run_3", "output.delta", {"text": "two"})
|
|
75
|
+
|
|
76
|
+
current = await store.get("run_3")
|
|
77
|
+
assert current is not None
|
|
78
|
+
assert current.status == "failed"
|
|
79
|
+
assert current.events[-1]["type"] == "run.failed"
|
|
80
|
+
|
|
81
|
+
with pytest.raises(RunStoreCapacityError):
|
|
82
|
+
await store.create_or_get(fixture("run_4", "key_4"))
|
|
83
|
+
store.close()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def fixture(run_id: str, idempotency_key: str) -> RunRecord:
|
|
87
|
+
return RunRecord(
|
|
88
|
+
run_id=run_id,
|
|
89
|
+
operation_key="test.echo",
|
|
90
|
+
input={"text": "hello"},
|
|
91
|
+
idempotency_key=idempotency_key,
|
|
92
|
+
fingerprint=f"fingerprint-{run_id}",
|
|
93
|
+
)
|