create-windy 0.8.0 → 0.8.2
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/dist/cli.js +14 -8
- package/package.json +1 -1
- package/template/.windy-template.json +2 -2
- package/template/apps/agent-server/Dockerfile +1 -1
- package/template/apps/agent-server/README.md +1 -1
- package/template/apps/agent-server/src/southwind_agent_server/asgi.py +8 -0
- package/template/apps/agent-server/src/southwind_agent_server/attempt_context.py +9 -1
- package/template/apps/agent-server/src/southwind_agent_server/openai_provider.py +5 -2
- package/template/apps/agent-server/src/southwind_agent_server/provider_output_guidance.py +34 -0
- package/template/apps/agent-server/src/southwind_agent_server/runtime_logging.py +57 -0
- package/template/apps/agent-server/src/southwind_agent_server/structured_logging.py +145 -0
- package/template/apps/agent-server/src/southwind_agent_server/telemetry.py +4 -0
- package/template/apps/agent-server/tests/test_run_operational_logging.py +114 -0
- package/template/apps/agent-server/tests/test_structured_logging.py +142 -0
- package/template/apps/agent-server/tests/test_tool_structured_output.py +347 -0
- package/template/apps/server/src/agent/isolated-execution-guard.test.ts +194 -0
- package/template/apps/server/src/agent/isolated-execution-guard.ts +202 -0
- package/template/apps/server/src/agent/run-definition-support.ts +11 -1
- package/template/apps/server/src/agent/run-facade.ts +7 -11
- package/template/apps/server/src/agent/run-operation-policy.ts +11 -0
- package/template/apps/server/src/agent/workflow-durable-runtime-input.ts +8 -5
- package/template/apps/server/src/jobs/legacy-service-runtime.ts +4 -0
- package/template/apps/server/src/jobs/runtime-logging.test.ts +212 -0
- package/template/apps/server/src/jobs/runtime-logging.ts +58 -0
- package/template/apps/server/src/jobs/worker.ts +54 -12
- package/template/apps/server/src/observability/bootstrap.ts +1 -4
- package/template/apps/server/src/observability/http-observability.ts +2 -3
- package/template/apps/server/src/observability/structured-logger.test.ts +31 -0
- package/template/apps/server/src/observability/structured-logger.ts +27 -0
- package/template/apps/server/src/platform-bootstrap.ts +9 -3
- package/template/apps/server/src/startup-finalization.ts +3 -3
- package/template/apps/server/src/system/built-in-roles.test.ts +23 -0
- package/template/apps/server/src/system/built-in-roles.ts +10 -4
- package/template/apps/web/src/pages/system/composables/useResourceEditor.ts +16 -5
- package/template/apps/web/src/pages/system/composables/useResourceEditor.webtest.ts +4 -0
- package/template/docs/architecture/log-governance-implementation.md +96 -0
- package/template/docs/architecture/log-governance-plan.md +39 -0
- package/template/docs/operations/observability.md +36 -4
- package/template/docs/platform/agent-runtime.md +18 -0
- package/template/packages/modules/package.json +1 -1
- package/template/packages/modules/src/ai-isolated-execution-policy.test.ts +70 -0
- package/template/packages/modules/src/ai-isolated-execution-policy.ts +76 -0
- package/template/packages/modules/src/ai-operation-composition.ts +2 -0
- package/template/packages/modules/src/manifest.ts +23 -0
- package/template/packages/modules/src/system-features.ts +11 -10
- package/template/packages/server-sdk/package.json +1 -1
- package/template/packages/shared/index.ts +1 -0
- package/template/packages/shared/package.json +3 -2
- package/template/packages/shared/src/structured-log.test.ts +147 -0
- package/template/packages/shared/src/structured-log.ts +215 -0
package/dist/cli.js
CHANGED
|
@@ -2022,7 +2022,14 @@ var nativeSymbols = {
|
|
|
2022
2022
|
};
|
|
2023
2023
|
var directoryFlags = constants2.O_RDONLY | constants2.O_DIRECTORY | constants2.O_NOFOLLOW;
|
|
2024
2024
|
var removeDirectoryFlag = process.platform === "darwin" ? 128 : 512;
|
|
2025
|
-
var getLibc = createLazyLibcLoader((candidate) =>
|
|
2025
|
+
var getLibc = createLazyLibcLoader((candidate) => {
|
|
2026
|
+
const libc = dlopen2(candidate, nativeSymbols);
|
|
2027
|
+
const errnoSymbol = process.platform === "darwin" ? libc.symbols.__error : libc.symbols.__errno_location;
|
|
2028
|
+
const errnoPointer = errnoSymbol();
|
|
2029
|
+
if (!errnoPointer)
|
|
2030
|
+
throw new Error("\u65E0\u6CD5\u8BFB\u53D6\u539F\u751F\u9519\u8BEF\u7801\uFF0C\u4E8B\u52A1\u5B89\u5168\u5173\u95ED");
|
|
2031
|
+
return { symbols: libc.symbols, errnoPointer };
|
|
2032
|
+
}, "\u5F53\u524D\u5E73\u53F0\u65E0\u6CD5\u52A0\u8F7D\u5B89\u5168\u76EE\u5F55\u53E5\u67C4 API");
|
|
2026
2033
|
function resolveSafeTarget(root, path) {
|
|
2027
2034
|
if (!path || isAbsolute(path) || path.includes("\x00") || path.split(/[\\/]/).includes("..")) {
|
|
2028
2035
|
throw new Error(`\u66F4\u65B0\u8BA1\u5212\u5305\u542B\u4E0D\u5B89\u5168\u8DEF\u5F84\uFF1A${path}`);
|
|
@@ -2196,12 +2203,7 @@ function nativeUnlinkAt(directoryFd, name, flags) {
|
|
|
2196
2203
|
}
|
|
2197
2204
|
function nativeError(syscall, path) {
|
|
2198
2205
|
const libc = getLibc();
|
|
2199
|
-
const
|
|
2200
|
-
const errnoPointer = errnoSymbol();
|
|
2201
|
-
if (!errnoPointer) {
|
|
2202
|
-
throw new Error(`\u65E0\u6CD5\u8BFB\u53D6 ${syscall} \u7684\u539F\u751F\u9519\u8BEF\u7801\uFF0C\u4E8B\u52A1\u5B89\u5168\u5173\u95ED`);
|
|
2203
|
-
}
|
|
2204
|
-
const errno = read.i32(errnoPointer, 0);
|
|
2206
|
+
const errno = read.i32(libc.errnoPointer, 0);
|
|
2205
2207
|
return Object.assign(new Error(`${syscall} ${path} \u5931\u8D25`), {
|
|
2206
2208
|
code: errnoCode(errno),
|
|
2207
2209
|
errno: -errno,
|
|
@@ -4107,6 +4109,8 @@ var templateDocumentationFiles = [
|
|
|
4107
4109
|
"docs/architecture/mcp-business-capabilities.md",
|
|
4108
4110
|
"docs/architecture/migration-bundle.md",
|
|
4109
4111
|
"docs/architecture/module-host.md",
|
|
4112
|
+
"docs/architecture/log-governance-plan.md",
|
|
4113
|
+
"docs/architecture/log-governance-implementation.md",
|
|
4110
4114
|
"docs/architecture/module-manifest-composition.md",
|
|
4111
4115
|
"docs/architecture/module-secret-resolution.md",
|
|
4112
4116
|
"docs/architecture/object-storage.md",
|
|
@@ -8524,7 +8528,9 @@ var recipes = [
|
|
|
8524
8528
|
id: "starter-0.7.0-to-0.8.0",
|
|
8525
8529
|
from: "0.7.0",
|
|
8526
8530
|
to: "0.8.0"
|
|
8527
|
-
}
|
|
8531
|
+
},
|
|
8532
|
+
{ id: "starter-0.8.0-to-0.8.1", from: "0.8.0", to: "0.8.1" },
|
|
8533
|
+
{ id: "starter-0.8.1-to-0.8.2", from: "0.8.1", to: "0.8.2" }
|
|
8528
8534
|
];
|
|
8529
8535
|
function resolveRecipeChain(sourceVersion, targetVersion) {
|
|
8530
8536
|
if (compareVersions2(sourceVersion, minimumSupportedUpdateVersion) < 0) {
|
package/package.json
CHANGED
|
@@ -24,4 +24,4 @@ RUN mkdir -p /app/data/agent \
|
|
|
24
24
|
USER windy
|
|
25
25
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=4 \
|
|
26
26
|
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/ready', timeout=3)"]
|
|
27
|
-
CMD ["uvicorn", "southwind_agent_server.
|
|
27
|
+
CMD ["uvicorn", "southwind_agent_server.asgi:app", "--host", "0.0.0.0", "--port", "8080"]
|
|
@@ -43,7 +43,7 @@ export AI_PROVIDER_INPUT_MODALITIES='text'
|
|
|
43
43
|
export AGENT_RUN_STORE_PATH='.data/agent-runs.sqlite3'
|
|
44
44
|
export AI_PROVIDER_AUDIT_STORE_PATH='.data/provider-audit.sqlite3'
|
|
45
45
|
export AI_PROVIDER_AUDIT_RETENTION_DAYS='90'
|
|
46
|
-
uv run uvicorn southwind_agent_server.
|
|
46
|
+
uv run uvicorn southwind_agent_server.asgi:app
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
公网 URL 必须使用 HTTPS 且 Host 精确命中 allowlist;本机、容器服务名和内网地址允许
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from dataclasses import dataclass
|
|
3
|
+
from dataclasses import dataclass, replace
|
|
4
4
|
|
|
5
5
|
from .attempt_context_projection import (
|
|
6
6
|
AttemptContextPreparation,
|
|
@@ -15,6 +15,7 @@ from .contracts import JsonValue, LongSessionContext
|
|
|
15
15
|
from .engine import EngineRunRequest, PreparedArtifact
|
|
16
16
|
from .openai_provider import OpenAiCompatibleProvider
|
|
17
17
|
from .provider_governance import GovernedAttempt, ProviderRequestGovernance
|
|
18
|
+
from .provider_output_guidance import tool_output_messages
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
@dataclass(frozen=True)
|
|
@@ -64,6 +65,13 @@ async def govern_provider_attempt(
|
|
|
64
65
|
)
|
|
65
66
|
),
|
|
66
67
|
)
|
|
68
|
+
if governed.tools:
|
|
69
|
+
governed = replace(
|
|
70
|
+
governed,
|
|
71
|
+
messages=tool_output_messages(
|
|
72
|
+
governed.messages, governed.output_schema
|
|
73
|
+
),
|
|
74
|
+
)
|
|
67
75
|
provider_request = provider.request_body(
|
|
68
76
|
governed.messages,
|
|
69
77
|
governed.tools,
|
|
@@ -14,6 +14,7 @@ from .config import (
|
|
|
14
14
|
from .contracts import AgentErrorCode, JsonValue, RuntimeToolDefinition
|
|
15
15
|
from .engine import SafeEngineError
|
|
16
16
|
from .multimodal import response_format
|
|
17
|
+
from .provider_output_guidance import tool_output_messages
|
|
17
18
|
from .report_types import UsageFacts
|
|
18
19
|
from .vision_probe import probe_vision
|
|
19
20
|
|
|
@@ -97,6 +98,9 @@ class OpenAiCompatibleProvider:
|
|
|
97
98
|
output_schema: dict[str, JsonValue] | None = None,
|
|
98
99
|
max_output_tokens: int | None = None,
|
|
99
100
|
) -> bytes:
|
|
101
|
+
structured = response_format(output_schema_key, output_schema)
|
|
102
|
+
if tools:
|
|
103
|
+
messages = tool_output_messages(messages, output_schema)
|
|
100
104
|
payload: dict[str, JsonValue] = {
|
|
101
105
|
"model": self.config.model,
|
|
102
106
|
"messages": cast(JsonValue, messages),
|
|
@@ -105,8 +109,7 @@ class OpenAiCompatibleProvider:
|
|
|
105
109
|
}
|
|
106
110
|
if tools:
|
|
107
111
|
payload["tools"] = [tool_schema(tool) for tool in tools]
|
|
108
|
-
structured
|
|
109
|
-
if structured is not None:
|
|
112
|
+
if structured is not None and not tools:
|
|
110
113
|
payload["response_format"] = structured
|
|
111
114
|
if max_output_tokens is not None:
|
|
112
115
|
payload["max_tokens"] = max_output_tokens
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from .contracts import JsonValue
|
|
6
|
+
from .engine import SafeEngineError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def tool_output_messages(
|
|
10
|
+
messages: list[dict[str, JsonValue]],
|
|
11
|
+
output_schema: dict[str, JsonValue] | None,
|
|
12
|
+
) -> list[dict[str, JsonValue]]:
|
|
13
|
+
"""Merge already-governed schema guidance without moving Context source ordinals."""
|
|
14
|
+
if output_schema is None:
|
|
15
|
+
return messages
|
|
16
|
+
if not messages or messages[0].get("role") != "system":
|
|
17
|
+
raise SafeEngineError(
|
|
18
|
+
"INVALID_INPUT", "工具结构化输出缺少系统指令", retryable=False
|
|
19
|
+
)
|
|
20
|
+
instructions = messages[0].get("content")
|
|
21
|
+
if not isinstance(instructions, str):
|
|
22
|
+
raise SafeEngineError(
|
|
23
|
+
"INVALID_INPUT", "工具结构化输出指令无效", retryable=False
|
|
24
|
+
)
|
|
25
|
+
guidance = (
|
|
26
|
+
"\n\n需要工具时,只通过 API 的 function calling 调用已提供的工具,"
|
|
27
|
+
"不要在正文中模拟工具调用。工具处理完成后的最终回复必须是符合以下 "
|
|
28
|
+
"JSON Schema 的单个 JSON 值,不含 Markdown 围栏或额外说明;"
|
|
29
|
+
"工具调用阶段不受最终 JSON 格式限制。\n"
|
|
30
|
+
+ json.dumps(output_schema, ensure_ascii=False, separators=(",", ":"))
|
|
31
|
+
)
|
|
32
|
+
if instructions.endswith(guidance):
|
|
33
|
+
return messages
|
|
34
|
+
return [{**messages[0], "content": instructions + guidance}, *messages[1:]]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Safe attempt diagnostics, separate from the canonical Run store and audit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from .store import RunRecord
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AgentOperationalAttempt:
|
|
14
|
+
def __init__(self, record: RunRecord) -> None:
|
|
15
|
+
self._run_id = record.run_id
|
|
16
|
+
self._trace_id = record.trace_id
|
|
17
|
+
self._started_at: float | None = None
|
|
18
|
+
self._finished = False
|
|
19
|
+
|
|
20
|
+
def start(self) -> None:
|
|
21
|
+
if self._started_at is not None:
|
|
22
|
+
return
|
|
23
|
+
self._started_at = time.monotonic()
|
|
24
|
+
self._emit("agent.run.attempt.started")
|
|
25
|
+
|
|
26
|
+
def finish(self, status: str) -> None:
|
|
27
|
+
if self._started_at is None or self._finished:
|
|
28
|
+
return
|
|
29
|
+
self._finished = True
|
|
30
|
+
self._emit(
|
|
31
|
+
"agent.run.attempt.completed",
|
|
32
|
+
operational_status=status,
|
|
33
|
+
operational_duration_ms=round(
|
|
34
|
+
(time.monotonic() - self._started_at) * 1_000, 3
|
|
35
|
+
),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def _emit(self, event: str, **fields: str | float) -> None:
|
|
39
|
+
try:
|
|
40
|
+
emit = (
|
|
41
|
+
logger.warning
|
|
42
|
+
if event == "agent.run.attempt.completed"
|
|
43
|
+
and fields.get("operational_status") != "completed"
|
|
44
|
+
else logger.info
|
|
45
|
+
)
|
|
46
|
+
emit(
|
|
47
|
+
"Agent 执行阶段",
|
|
48
|
+
extra={
|
|
49
|
+
"operational_event": event,
|
|
50
|
+
"run_id": self._run_id,
|
|
51
|
+
"trace_id": self._trace_id,
|
|
52
|
+
**fields,
|
|
53
|
+
},
|
|
54
|
+
)
|
|
55
|
+
except Exception:
|
|
56
|
+
# 日志出口失败不能改变 Run 或租约的业务结果。
|
|
57
|
+
return
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Agent runtime adapter for the Windy operational log contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import math
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from typing import TextIO
|
|
12
|
+
|
|
13
|
+
MAX_LOG_BYTES = 2048
|
|
14
|
+
_CORRELATION = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
|
|
15
|
+
_STABLE = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
|
|
16
|
+
_EVENTS = {
|
|
17
|
+
"Agent Run 恢复轮询失败,将继续重试": (
|
|
18
|
+
"agent.run.recovery.failed",
|
|
19
|
+
"AGENT_RUN_RECOVERY_FAILED",
|
|
20
|
+
),
|
|
21
|
+
"Agent Runtime 报表写入失败": (
|
|
22
|
+
"agent.report.write.failed",
|
|
23
|
+
"AGENT_REPORT_WRITE_FAILED",
|
|
24
|
+
),
|
|
25
|
+
}
|
|
26
|
+
_ATTEMPT_EVENTS = frozenset(
|
|
27
|
+
{
|
|
28
|
+
"agent.run.attempt.started",
|
|
29
|
+
"agent.run.attempt.completed",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
_ATTEMPT_ERRORS = {
|
|
33
|
+
"failed": "AGENT_RUN_FAILED",
|
|
34
|
+
"cancelled": "AGENT_RUN_CANCELLED",
|
|
35
|
+
"interrupted": "AGENT_RUN_INTERRUPTED",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def agent_log_record(record: logging.LogRecord) -> dict[str, str | bool | float]:
|
|
40
|
+
"""Map known runtime failures without serializing messages, errors or extras."""
|
|
41
|
+
event, error_code = _EVENTS.get(
|
|
42
|
+
record.msg if isinstance(record.msg, str) else "",
|
|
43
|
+
("agent.runtime.failed", "AGENT_RUNTIME_FAILED"),
|
|
44
|
+
)
|
|
45
|
+
attempt_event = getattr(record, "operational_event", None)
|
|
46
|
+
is_attempt = isinstance(attempt_event, str) and attempt_event in _ATTEMPT_EVENTS
|
|
47
|
+
result: dict[str, str | bool | float] = {
|
|
48
|
+
"timestamp": datetime.now(UTC)
|
|
49
|
+
.isoformat(timespec="milliseconds")
|
|
50
|
+
.replace("+00:00", "Z"),
|
|
51
|
+
"level": "error"
|
|
52
|
+
if record.levelno >= logging.ERROR
|
|
53
|
+
else ("warn" if record.levelno >= logging.WARNING else "info"),
|
|
54
|
+
"component": "agent-server",
|
|
55
|
+
"event": attempt_event
|
|
56
|
+
if isinstance(attempt_event, str) and is_attempt
|
|
57
|
+
else event,
|
|
58
|
+
"retentionClass": "operational",
|
|
59
|
+
}
|
|
60
|
+
if not is_attempt:
|
|
61
|
+
result.update(outcome="failure", errorCode=error_code)
|
|
62
|
+
elif attempt_event == "agent.run.attempt.completed":
|
|
63
|
+
status = getattr(record, "operational_status", "interrupted")
|
|
64
|
+
if status not in {"completed", "failed", "cancelled"}:
|
|
65
|
+
status = "interrupted"
|
|
66
|
+
result["stageKey"] = status
|
|
67
|
+
result["outcome"] = "success" if status == "completed" else "failure"
|
|
68
|
+
if status != "completed":
|
|
69
|
+
result["errorCode"] = _ATTEMPT_ERRORS[status]
|
|
70
|
+
duration = getattr(record, "operational_duration_ms", None)
|
|
71
|
+
if (
|
|
72
|
+
isinstance(duration, (int, float))
|
|
73
|
+
and not isinstance(duration, bool)
|
|
74
|
+
and math.isfinite(duration)
|
|
75
|
+
and 0 <= duration <= 3_600_000
|
|
76
|
+
):
|
|
77
|
+
result["durationMs"] = duration
|
|
78
|
+
for source, target in (
|
|
79
|
+
("request_id", "requestId"),
|
|
80
|
+
("run_id", "runId"),
|
|
81
|
+
("job_id", "jobId"),
|
|
82
|
+
("trace_id", "traceId"),
|
|
83
|
+
):
|
|
84
|
+
value = getattr(record, source, None)
|
|
85
|
+
if isinstance(value, str) and _CORRELATION.fullmatch(value):
|
|
86
|
+
result[target] = value
|
|
87
|
+
stage = getattr(record, "report_stage", None)
|
|
88
|
+
if isinstance(stage, str) and len(stage) <= 64 and _STABLE.fullmatch(stage):
|
|
89
|
+
result["stageKey"] = stage
|
|
90
|
+
for key in ("stageKey", "traceId", "jobId", "runId", "requestId"):
|
|
91
|
+
if len(json.dumps(result, ensure_ascii=False).encode("utf-8")) <= MAX_LOG_BYTES:
|
|
92
|
+
break
|
|
93
|
+
result.pop(key, None)
|
|
94
|
+
result["truncated"] = True
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AgentStructuredLogHandler(logging.Handler):
|
|
99
|
+
def __init__(self, stream: TextIO | None = None) -> None:
|
|
100
|
+
super().__init__(level=logging.INFO)
|
|
101
|
+
self.stream = stream or sys.stdout
|
|
102
|
+
|
|
103
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
104
|
+
try:
|
|
105
|
+
if (
|
|
106
|
+
record.levelno < logging.WARNING
|
|
107
|
+
and getattr(record, "operational_event", None) not in _ATTEMPT_EVENTS
|
|
108
|
+
):
|
|
109
|
+
return
|
|
110
|
+
line = json.dumps(
|
|
111
|
+
agent_log_record(record), ensure_ascii=False, separators=(",", ":")
|
|
112
|
+
)
|
|
113
|
+
self.stream.write(f"{line}\n")
|
|
114
|
+
self.flush()
|
|
115
|
+
except Exception:
|
|
116
|
+
# 运行日志旁路失败时不能改变 Run 与报表写入的业务结果。
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
def flush(self) -> None:
|
|
120
|
+
try:
|
|
121
|
+
self.stream.flush()
|
|
122
|
+
except Exception:
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def configure_agent_logging() -> None:
|
|
127
|
+
"""Use one package sink without closing handlers shared with Uvicorn."""
|
|
128
|
+
logger = logging.getLogger("southwind_agent_server")
|
|
129
|
+
structured = next(
|
|
130
|
+
(
|
|
131
|
+
handler
|
|
132
|
+
for handler in logger.handlers
|
|
133
|
+
if isinstance(handler, AgentStructuredLogHandler)
|
|
134
|
+
),
|
|
135
|
+
None,
|
|
136
|
+
)
|
|
137
|
+
if structured is None:
|
|
138
|
+
structured = AgentStructuredLogHandler()
|
|
139
|
+
for handler in logger.handlers[:]:
|
|
140
|
+
if handler is not structured:
|
|
141
|
+
logger.removeHandler(handler)
|
|
142
|
+
if structured not in logger.handlers:
|
|
143
|
+
logger.addHandler(structured)
|
|
144
|
+
logger.setLevel(logging.INFO)
|
|
145
|
+
logger.propagate = False
|
|
@@ -17,6 +17,7 @@ from opentelemetry.trace import (
|
|
|
17
17
|
TraceFlags,
|
|
18
18
|
)
|
|
19
19
|
|
|
20
|
+
from .runtime_logging import AgentOperationalAttempt
|
|
20
21
|
from .store import RunRecord
|
|
21
22
|
|
|
22
23
|
_configured = False
|
|
@@ -50,11 +51,14 @@ class AgentRunTrace:
|
|
|
50
51
|
},
|
|
51
52
|
)
|
|
52
53
|
self._token: Token[Context] | None = None
|
|
54
|
+
self._operational = AgentOperationalAttempt(record)
|
|
53
55
|
|
|
54
56
|
def start(self) -> None:
|
|
55
57
|
self._token = context.attach(trace.set_span_in_context(self._span))
|
|
58
|
+
self._operational.start()
|
|
56
59
|
|
|
57
60
|
def finish(self, status: str) -> None:
|
|
61
|
+
self._operational.finish(status)
|
|
58
62
|
self._span.set_attribute("agent.run.status", status)
|
|
59
63
|
if self._token is not None:
|
|
60
64
|
context.detach(self._token)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from typing import TextIO, cast
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from southwind_agent_server.app import create_app
|
|
14
|
+
from southwind_agent_server.deterministic import DeterministicEngineAdapter
|
|
15
|
+
from southwind_agent_server.runtime_logging import AgentOperationalAttempt, logger
|
|
16
|
+
from southwind_agent_server.service import AgentRunService
|
|
17
|
+
from southwind_agent_server.store import RunRecord
|
|
18
|
+
from southwind_agent_server.structured_logging import AgentStructuredLogHandler
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture(autouse=True)
|
|
22
|
+
def operational_log_level() -> Iterator[None]:
|
|
23
|
+
previous = logger.level
|
|
24
|
+
logger.setLevel(logging.INFO)
|
|
25
|
+
try:
|
|
26
|
+
yield
|
|
27
|
+
finally:
|
|
28
|
+
logger.setLevel(previous)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@pytest.mark.parametrize("broken_sink", [False, True])
|
|
32
|
+
async def test_real_run_completion_and_replay_with_operational_logs(
|
|
33
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
34
|
+
broken_sink: bool,
|
|
35
|
+
) -> None:
|
|
36
|
+
class Stream(io.StringIO):
|
|
37
|
+
def write(self, value: str) -> int:
|
|
38
|
+
written = super().write(value)
|
|
39
|
+
if broken_sink:
|
|
40
|
+
raise OSError("sink-secret")
|
|
41
|
+
return written
|
|
42
|
+
|
|
43
|
+
stream = Stream()
|
|
44
|
+
monkeypatch.setattr(logger, "handlers", [AgentStructuredLogHandler(stream)])
|
|
45
|
+
monkeypatch.setattr(logger, "propagate", False)
|
|
46
|
+
app = create_app(DeterministicEngineAdapter(), heartbeat_seconds=0.01)
|
|
47
|
+
service = cast(AgentRunService, app.state.run_service)
|
|
48
|
+
async with httpx.AsyncClient(
|
|
49
|
+
transport=httpx.ASGITransport(app=app),
|
|
50
|
+
base_url="http://agent.test",
|
|
51
|
+
) as client:
|
|
52
|
+
|
|
53
|
+
async def start() -> httpx.Response:
|
|
54
|
+
return await client.post(
|
|
55
|
+
"/api/agent/runs",
|
|
56
|
+
headers={"Idempotency-Key": "operational-replay"},
|
|
57
|
+
json={
|
|
58
|
+
"operationKey": "test.echo",
|
|
59
|
+
"input": {"text": "input-secret"},
|
|
60
|
+
"traceId": "1" * 32,
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
response = await start()
|
|
65
|
+
assert response.status_code == 200
|
|
66
|
+
run_id = str(response.json()["runId"])
|
|
67
|
+
await asyncio.gather(*service._tasks)
|
|
68
|
+
replay = await start()
|
|
69
|
+
assert replay.json()["runId"] == run_id
|
|
70
|
+
events = await client.get(f"/api/agent/runs/{run_id}/events")
|
|
71
|
+
result = await client.get(f"/api/agent/runs/{run_id}/result")
|
|
72
|
+
assert result.json()["status"] == "completed"
|
|
73
|
+
assert result.json()["output"] == {"text": "input-secret"}
|
|
74
|
+
assert "run.failed" not in events.text
|
|
75
|
+
records = [json.loads(line) for line in stream.getvalue().splitlines()]
|
|
76
|
+
assert [record["event"] for record in records] == [
|
|
77
|
+
"agent.run.attempt.started",
|
|
78
|
+
"agent.run.attempt.completed",
|
|
79
|
+
]
|
|
80
|
+
assert all(record["runId"] == run_id for record in records)
|
|
81
|
+
assert all(record["traceId"] == "1" * 32 for record in records)
|
|
82
|
+
assert records[-1]["outcome"] == "success"
|
|
83
|
+
assert records[-1]["durationMs"] >= 0
|
|
84
|
+
assert "input-secret" not in stream.getvalue()
|
|
85
|
+
assert "sink-secret" not in stream.getvalue()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.parametrize("status", ["failed", "cancelled", "running"])
|
|
89
|
+
def test_attempt_terminal_mapping_and_duplicate_finish_are_safe(
|
|
90
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
91
|
+
status: str,
|
|
92
|
+
) -> None:
|
|
93
|
+
stream = io.StringIO()
|
|
94
|
+
monkeypatch.setattr(logger, "handlers", [AgentStructuredLogHandler(stream)])
|
|
95
|
+
monkeypatch.setattr(logger, "propagate", False)
|
|
96
|
+
record = RunRecord("run-1", "test.echo", {"secret": "input"}, "key", "digest")
|
|
97
|
+
attempt = AgentOperationalAttempt(record)
|
|
98
|
+
attempt.start()
|
|
99
|
+
attempt.start()
|
|
100
|
+
attempt.finish(status)
|
|
101
|
+
attempt.finish(status)
|
|
102
|
+
records = [json.loads(line) for line in stream.getvalue().splitlines()]
|
|
103
|
+
assert len(records) == 2
|
|
104
|
+
assert records[-1]["outcome"] == "failure"
|
|
105
|
+
expected = status if status in {"failed", "cancelled"} else "interrupted"
|
|
106
|
+
assert records[-1]["errorCode"] == f"AGENT_RUN_{expected.upper()}"
|
|
107
|
+
assert "secret" not in stream.getvalue()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_unknown_info_message_does_not_expand_log_content() -> None:
|
|
111
|
+
stream = io.StringIO()
|
|
112
|
+
handler = AgentStructuredLogHandler(cast(TextIO, stream))
|
|
113
|
+
handler.emit(logging.makeLogRecord({"levelno": logging.INFO, "msg": "secret"}))
|
|
114
|
+
assert stream.getvalue() == ""
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
from importlib import import_module
|
|
5
|
+
from typing import TextIO, cast
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from southwind_agent_server.structured_logging import (
|
|
10
|
+
MAX_LOG_BYTES,
|
|
11
|
+
AgentStructuredLogHandler,
|
|
12
|
+
agent_log_record,
|
|
13
|
+
configure_agent_logging,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_agent_report_failure_keeps_only_safe_correlation() -> None:
|
|
18
|
+
stream = io.StringIO()
|
|
19
|
+
handler = AgentStructuredLogHandler(stream)
|
|
20
|
+
logger = logging.getLogger("southwind_agent_server.test_structured_logging")
|
|
21
|
+
logger.addHandler(handler)
|
|
22
|
+
logger.setLevel(logging.WARNING)
|
|
23
|
+
logger.propagate = False
|
|
24
|
+
try:
|
|
25
|
+
try:
|
|
26
|
+
raise ValueError("secret-error")
|
|
27
|
+
except ValueError:
|
|
28
|
+
logger.exception(
|
|
29
|
+
"Agent Runtime 报表写入失败",
|
|
30
|
+
extra={
|
|
31
|
+
"run_id": "run-1",
|
|
32
|
+
"report_stage": "run_terminal",
|
|
33
|
+
"request_id": "bad\nrequest-secret",
|
|
34
|
+
"body": "secret-body",
|
|
35
|
+
"authorization": "Bearer secret-token",
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
finally:
|
|
39
|
+
logger.removeHandler(handler)
|
|
40
|
+
line = stream.getvalue().strip()
|
|
41
|
+
parsed = json.loads(line)
|
|
42
|
+
assert parsed["event"] == "agent.report.write.failed"
|
|
43
|
+
assert parsed["errorCode"] == "AGENT_REPORT_WRITE_FAILED"
|
|
44
|
+
assert parsed["runId"] == "run-1"
|
|
45
|
+
assert parsed["stageKey"] == "run_terminal"
|
|
46
|
+
assert parsed["retentionClass"] == "operational"
|
|
47
|
+
assert len(line.encode("utf-8")) <= MAX_LOG_BYTES
|
|
48
|
+
assert all(
|
|
49
|
+
secret not in line
|
|
50
|
+
for secret in (
|
|
51
|
+
"request-secret",
|
|
52
|
+
"secret-body",
|
|
53
|
+
"secret-token",
|
|
54
|
+
"secret-error",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_unknown_message_and_long_fields_are_bounded() -> None:
|
|
60
|
+
record = logging.makeLogRecord(
|
|
61
|
+
{
|
|
62
|
+
"name": "southwind_agent_server.service",
|
|
63
|
+
"levelno": logging.ERROR,
|
|
64
|
+
"msg": "secret-message",
|
|
65
|
+
"run_id": "x" * 500,
|
|
66
|
+
"report_stage": "payload secret",
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
safe = agent_log_record(record)
|
|
70
|
+
assert safe["event"] == "agent.runtime.failed"
|
|
71
|
+
assert "runId" not in safe
|
|
72
|
+
assert "stageKey" not in safe
|
|
73
|
+
assert "secret-message" not in json.dumps(safe)
|
|
74
|
+
assert len(json.dumps(safe).encode("utf-8")) <= MAX_LOG_BYTES
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_asgi_entrypoint_installs_handler_without_changing_app() -> None:
|
|
78
|
+
from southwind_agent_server.app import app
|
|
79
|
+
|
|
80
|
+
asgi = import_module("southwind_agent_server.asgi")
|
|
81
|
+
configure_agent_logging()
|
|
82
|
+
handlers = [
|
|
83
|
+
handler
|
|
84
|
+
for handler in logging.getLogger("southwind_agent_server").handlers
|
|
85
|
+
if isinstance(handler, AgentStructuredLogHandler)
|
|
86
|
+
]
|
|
87
|
+
assert asgi.app is app
|
|
88
|
+
assert len(handlers) == 1
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_stdout_failure_does_not_interrupt_runtime() -> None:
|
|
92
|
+
class BrokenStream:
|
|
93
|
+
def write(self, _value: str) -> None:
|
|
94
|
+
raise OSError("stdout unavailable")
|
|
95
|
+
|
|
96
|
+
def flush(self) -> None:
|
|
97
|
+
raise OSError("stdout unavailable")
|
|
98
|
+
|
|
99
|
+
handler = AgentStructuredLogHandler(cast(TextIO, BrokenStream()))
|
|
100
|
+
record = logging.makeLogRecord(
|
|
101
|
+
{
|
|
102
|
+
"name": "southwind_agent_server.service",
|
|
103
|
+
"levelno": logging.ERROR,
|
|
104
|
+
"msg": "Agent Run 恢复轮询失败,将继续重试",
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
handler.emit(record)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_bootstrap_removes_borrowed_sink_without_closing_uvicorn(
|
|
111
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
112
|
+
) -> None:
|
|
113
|
+
stream = io.StringIO()
|
|
114
|
+
legacy_stream = io.StringIO()
|
|
115
|
+
legacy = logging.StreamHandler(legacy_stream)
|
|
116
|
+
structured = AgentStructuredLogHandler(stream)
|
|
117
|
+
logger = logging.getLogger("southwind_agent_server")
|
|
118
|
+
uvicorn = logging.getLogger("uvicorn")
|
|
119
|
+
monkeypatch.setattr(logger, "handlers", [legacy, structured])
|
|
120
|
+
monkeypatch.setattr(logger, "level", logging.INFO)
|
|
121
|
+
monkeypatch.setattr(logger, "propagate", False)
|
|
122
|
+
monkeypatch.setattr(uvicorn, "handlers", [legacy])
|
|
123
|
+
monkeypatch.setattr(uvicorn, "level", logging.INFO)
|
|
124
|
+
monkeypatch.setattr(uvicorn, "propagate", False)
|
|
125
|
+
closed: list[bool] = []
|
|
126
|
+
monkeypatch.setattr(legacy, "close", lambda: closed.append(True))
|
|
127
|
+
|
|
128
|
+
configure_agent_logging()
|
|
129
|
+
configure_agent_logging()
|
|
130
|
+
assert logger.handlers == [structured]
|
|
131
|
+
assert uvicorn.handlers == [legacy]
|
|
132
|
+
assert not closed
|
|
133
|
+
try:
|
|
134
|
+
raise ValueError("secret-exception")
|
|
135
|
+
except ValueError:
|
|
136
|
+
logger.exception("Agent Run 恢复轮询失败,将继续重试")
|
|
137
|
+
assert len(stream.getvalue().splitlines()) == 1
|
|
138
|
+
assert json.loads(stream.getvalue())["errorCode"] == "AGENT_RUN_RECOVERY_FAILED"
|
|
139
|
+
assert legacy_stream.getvalue() == ""
|
|
140
|
+
assert "secret-exception" not in stream.getvalue()
|
|
141
|
+
uvicorn.warning("independent-server-warning")
|
|
142
|
+
assert "independent-server-warning" in legacy_stream.getvalue()
|