@kkelly-offical/kkcode 0.1.2 → 0.1.6
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 +120 -178
- package/package.json +46 -46
- package/src/agent/agent.mjs +41 -0
- package/src/agent/prompt/frontend-designer.txt +58 -0
- package/src/agent/prompt/longagent-blueprint-agent.txt +83 -0
- package/src/agent/prompt/longagent-coding-agent.txt +37 -0
- package/src/agent/prompt/longagent-debugging-agent.txt +46 -0
- package/src/agent/prompt/longagent-preview-agent.txt +63 -0
- package/src/config/defaults.mjs +260 -195
- package/src/config/schema.mjs +71 -6
- package/src/core/constants.mjs +91 -46
- package/src/index.mjs +1 -1
- package/src/knowledge/frontend-aesthetics.txt +39 -0
- package/src/knowledge/loader.mjs +2 -1
- package/src/knowledge/tailwind.txt +12 -3
- package/src/mcp/client-http.mjs +141 -157
- package/src/mcp/client-sse.mjs +288 -286
- package/src/mcp/client-stdio.mjs +533 -451
- package/src/mcp/constants.mjs +2 -0
- package/src/mcp/registry.mjs +479 -394
- package/src/mcp/stdio-framing.mjs +133 -127
- package/src/mcp/tool-result.mjs +24 -0
- package/src/observability/index.mjs +42 -0
- package/src/observability/metrics.mjs +137 -0
- package/src/observability/tracer.mjs +137 -0
- package/src/orchestration/background-manager.mjs +372 -358
- package/src/orchestration/background-worker.mjs +305 -245
- package/src/orchestration/longagent-manager.mjs +171 -116
- package/src/orchestration/stage-scheduler.mjs +728 -489
- package/src/permission/exec-policy.mjs +9 -11
- package/src/provider/anthropic.mjs +1 -0
- package/src/provider/openai.mjs +340 -339
- package/src/provider/retry-policy.mjs +68 -68
- package/src/provider/router.mjs +241 -228
- package/src/provider/sse.mjs +104 -91
- package/src/repl.mjs +1 -1
- package/src/session/checkpoint.mjs +66 -3
- package/src/session/engine.mjs +227 -225
- package/src/session/longagent-4stage.mjs +460 -0
- package/src/session/longagent-hybrid.mjs +1081 -0
- package/src/session/longagent-plan.mjs +365 -329
- package/src/session/longagent-project-memory.mjs +53 -0
- package/src/session/longagent-scaffold.mjs +291 -100
- package/src/session/longagent-task-bus.mjs +54 -0
- package/src/session/longagent-utils.mjs +472 -0
- package/src/session/longagent.mjs +884 -1462
- package/src/session/project-context.mjs +30 -0
- package/src/session/store.mjs +510 -503
- package/src/session/task-validator.mjs +4 -3
- package/src/skill/builtin/design.mjs +76 -0
- package/src/skill/builtin/frontend.mjs +8 -0
- package/src/skill/registry.mjs +390 -336
- package/src/storage/ghost-commit-store.mjs +18 -8
- package/src/tool/executor.mjs +11 -0
- package/src/tool/git-auto.mjs +0 -19
- package/src/tool/registry.mjs +71 -37
- package/src/ui/activity-renderer.mjs +664 -410
- package/src/util/git.mjs +23 -0
|
@@ -1,127 +1,133 @@
|
|
|
1
|
-
const CRLFCRLF = Buffer.from("\r\n\r\n", "utf8")
|
|
2
|
-
const NEWLINE = 0x0a
|
|
3
|
-
|
|
4
|
-
function toBuffer(chunk) {
|
|
5
|
-
if (Buffer.isBuffer(chunk)) return chunk
|
|
6
|
-
if (typeof chunk === "string") return Buffer.from(chunk, "utf8")
|
|
7
|
-
if (chunk instanceof Uint8Array) return Buffer.from(chunk)
|
|
8
|
-
return Buffer.from(String(chunk || ""), "utf8")
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
function parseContentLengthHeader(headerText) {
|
|
12
|
-
const match = /(?:^|\r?\n)content-length:\s*(\d+)\s*(?:\r?\n|$)/i.exec(headerText)
|
|
13
|
-
if (!match) return null
|
|
14
|
-
const len = Number(match[1])
|
|
15
|
-
if (!Number.isFinite(len) || len < 0) return null
|
|
16
|
-
return len
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function consumeContentLengthFrame(buffer, maxFrameBytes) {
|
|
20
|
-
const headerEnd = buffer.indexOf(CRLFCRLF)
|
|
21
|
-
if (headerEnd === -1) return { type: "need_more" }
|
|
22
|
-
const headerText = buffer.subarray(0, headerEnd).toString("utf8")
|
|
23
|
-
const length = parseContentLengthHeader(headerText)
|
|
24
|
-
if (length === null) return { type: "invalid_header" }
|
|
25
|
-
if (length > maxFrameBytes) return { type: "invalid_size", size: length }
|
|
26
|
-
const total = headerEnd + CRLFCRLF.length + length
|
|
27
|
-
if (buffer.length < total) return { type: "need_more" }
|
|
28
|
-
const payload = buffer
|
|
29
|
-
.subarray(headerEnd + CRLFCRLF.length, total)
|
|
30
|
-
.toString("utf8")
|
|
31
|
-
const rest = buffer.subarray(total)
|
|
32
|
-
return { type: "ok", payload, rest }
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function consumeNewlineFrame(buffer) {
|
|
36
|
-
const newlineIdx = buffer.indexOf(NEWLINE)
|
|
37
|
-
if (newlineIdx === -1) return { type: "need_more" }
|
|
38
|
-
const rawLine = buffer.subarray(0, newlineIdx).toString("utf8")
|
|
39
|
-
const rest = buffer.subarray(newlineIdx + 1)
|
|
40
|
-
const payload = rawLine.trim()
|
|
41
|
-
if (!payload) return { type: "empty", rest }
|
|
42
|
-
return { type: "ok", payload, rest }
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function dropLeadingCrlf(buffer) {
|
|
46
|
-
let cursor = 0
|
|
47
|
-
while (cursor < buffer.length) {
|
|
48
|
-
const c = buffer[cursor]
|
|
49
|
-
if (c !== 0x0d && c !== 0x0a) break
|
|
50
|
-
cursor += 1
|
|
51
|
-
}
|
|
52
|
-
return cursor > 0 ? buffer.subarray(cursor) : buffer
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function seemsContentLength(buffer) {
|
|
56
|
-
if (!buffer.length) return false
|
|
57
|
-
const probe = buffer.subarray(0, Math.min(buffer.length, 32)).toString("ascii").toLowerCase()
|
|
58
|
-
return probe.startsWith("content-length:")
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function encodeRpcMessage(message, framing = "content-length") {
|
|
62
|
-
const payload = JSON.stringify(message)
|
|
63
|
-
if (framing === "newline") return `${payload}\n`
|
|
64
|
-
const size = Buffer.byteLength(payload, "utf8")
|
|
65
|
-
return `Content-Length: ${size}\r\n\r\n${payload}`
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function createStdioFramingDecoder({ framing = "auto", maxFrameBytes = 8 * 1024 * 1024 } = {}) {
|
|
69
|
-
let buffer = Buffer.alloc(0)
|
|
70
|
-
|
|
71
|
-
function push(chunk) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
1
|
+
const CRLFCRLF = Buffer.from("\r\n\r\n", "utf8")
|
|
2
|
+
const NEWLINE = 0x0a
|
|
3
|
+
|
|
4
|
+
function toBuffer(chunk) {
|
|
5
|
+
if (Buffer.isBuffer(chunk)) return chunk
|
|
6
|
+
if (typeof chunk === "string") return Buffer.from(chunk, "utf8")
|
|
7
|
+
if (chunk instanceof Uint8Array) return Buffer.from(chunk)
|
|
8
|
+
return Buffer.from(String(chunk || ""), "utf8")
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseContentLengthHeader(headerText) {
|
|
12
|
+
const match = /(?:^|\r?\n)content-length:\s*(\d+)\s*(?:\r?\n|$)/i.exec(headerText)
|
|
13
|
+
if (!match) return null
|
|
14
|
+
const len = Number(match[1])
|
|
15
|
+
if (!Number.isFinite(len) || len < 0) return null
|
|
16
|
+
return len
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function consumeContentLengthFrame(buffer, maxFrameBytes) {
|
|
20
|
+
const headerEnd = buffer.indexOf(CRLFCRLF)
|
|
21
|
+
if (headerEnd === -1) return { type: "need_more" }
|
|
22
|
+
const headerText = buffer.subarray(0, headerEnd).toString("utf8")
|
|
23
|
+
const length = parseContentLengthHeader(headerText)
|
|
24
|
+
if (length === null) return { type: "invalid_header" }
|
|
25
|
+
if (length > maxFrameBytes) return { type: "invalid_size", size: length }
|
|
26
|
+
const total = headerEnd + CRLFCRLF.length + length
|
|
27
|
+
if (buffer.length < total) return { type: "need_more" }
|
|
28
|
+
const payload = buffer
|
|
29
|
+
.subarray(headerEnd + CRLFCRLF.length, total)
|
|
30
|
+
.toString("utf8")
|
|
31
|
+
const rest = buffer.subarray(total)
|
|
32
|
+
return { type: "ok", payload, rest }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function consumeNewlineFrame(buffer) {
|
|
36
|
+
const newlineIdx = buffer.indexOf(NEWLINE)
|
|
37
|
+
if (newlineIdx === -1) return { type: "need_more" }
|
|
38
|
+
const rawLine = buffer.subarray(0, newlineIdx).toString("utf8")
|
|
39
|
+
const rest = buffer.subarray(newlineIdx + 1)
|
|
40
|
+
const payload = rawLine.trim()
|
|
41
|
+
if (!payload) return { type: "empty", rest }
|
|
42
|
+
return { type: "ok", payload, rest }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function dropLeadingCrlf(buffer) {
|
|
46
|
+
let cursor = 0
|
|
47
|
+
while (cursor < buffer.length) {
|
|
48
|
+
const c = buffer[cursor]
|
|
49
|
+
if (c !== 0x0d && c !== 0x0a) break
|
|
50
|
+
cursor += 1
|
|
51
|
+
}
|
|
52
|
+
return cursor > 0 ? buffer.subarray(cursor) : buffer
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function seemsContentLength(buffer) {
|
|
56
|
+
if (!buffer.length) return false
|
|
57
|
+
const probe = buffer.subarray(0, Math.min(buffer.length, 32)).toString("ascii").toLowerCase()
|
|
58
|
+
return probe.startsWith("content-length:")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function encodeRpcMessage(message, framing = "content-length") {
|
|
62
|
+
const payload = JSON.stringify(message)
|
|
63
|
+
if (framing === "newline") return `${payload}\n`
|
|
64
|
+
const size = Buffer.byteLength(payload, "utf8")
|
|
65
|
+
return `Content-Length: ${size}\r\n\r\n${payload}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createStdioFramingDecoder({ framing = "auto", maxFrameBytes = 8 * 1024 * 1024, maxBufferBytes = 16 * 1024 * 1024 } = {}) {
|
|
69
|
+
let buffer = Buffer.alloc(0)
|
|
70
|
+
|
|
71
|
+
function push(chunk) {
|
|
72
|
+
const incoming = toBuffer(chunk)
|
|
73
|
+
if (buffer.length + incoming.length > maxBufferBytes) {
|
|
74
|
+
buffer = Buffer.alloc(0)
|
|
75
|
+
throw new Error(`stdio framing buffer exceeded limit: ${maxBufferBytes} bytes`)
|
|
76
|
+
}
|
|
77
|
+
buffer = Buffer.concat([buffer, incoming])
|
|
78
|
+
const messages = []
|
|
79
|
+
|
|
80
|
+
while (true) {
|
|
81
|
+
if (!buffer.length) break
|
|
82
|
+
|
|
83
|
+
if (framing === "content-length") {
|
|
84
|
+
const parsed = consumeContentLengthFrame(buffer, maxFrameBytes)
|
|
85
|
+
if (parsed.type === "need_more") break
|
|
86
|
+
if (parsed.type === "invalid_header") throw new Error("invalid content-length header")
|
|
87
|
+
if (parsed.type === "invalid_size") throw new Error(`content-length exceeds limit: ${parsed.size}`)
|
|
88
|
+
messages.push(parsed.payload)
|
|
89
|
+
buffer = parsed.rest
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (framing === "newline") {
|
|
94
|
+
const parsed = consumeNewlineFrame(buffer)
|
|
95
|
+
if (parsed.type === "need_more") break
|
|
96
|
+
buffer = parsed.rest
|
|
97
|
+
if (parsed.type === "ok") messages.push(parsed.payload)
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// auto mode: prefer standard content-length frames, then fallback to newline JSON
|
|
102
|
+
buffer = dropLeadingCrlf(buffer)
|
|
103
|
+
if (!buffer.length) break
|
|
104
|
+
|
|
105
|
+
if (seemsContentLength(buffer)) {
|
|
106
|
+
const parsed = consumeContentLengthFrame(buffer, maxFrameBytes)
|
|
107
|
+
if (parsed.type === "need_more") break
|
|
108
|
+
if (parsed.type === "invalid_header") throw new Error("invalid content-length header")
|
|
109
|
+
if (parsed.type === "invalid_size") throw new Error(`content-length exceeds limit: ${parsed.size}`)
|
|
110
|
+
messages.push(parsed.payload)
|
|
111
|
+
buffer = parsed.rest
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const parsed = consumeNewlineFrame(buffer)
|
|
116
|
+
if (parsed.type === "need_more") break
|
|
117
|
+
buffer = parsed.rest
|
|
118
|
+
if (parsed.type === "ok") messages.push(parsed.payload)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return messages
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function reset() {
|
|
125
|
+
buffer = Buffer.alloc(0)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
push,
|
|
130
|
+
reset,
|
|
131
|
+
bufferSize() { return buffer.length }
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { McpError } from "../core/errors.mjs"
|
|
2
|
+
|
|
3
|
+
export function normalizeToolResult(result, serverName, toolName) {
|
|
4
|
+
if (result?.isError) {
|
|
5
|
+
const text = Array.isArray(result.content)
|
|
6
|
+
? result.content.map((item) => item?.text || "").join("\n").trim()
|
|
7
|
+
: ""
|
|
8
|
+
throw new McpError(text || "mcp tool returned isError", {
|
|
9
|
+
reason: "bad_response",
|
|
10
|
+
server: serverName,
|
|
11
|
+
action: `tools/call:${toolName}`,
|
|
12
|
+
phase: "request"
|
|
13
|
+
})
|
|
14
|
+
}
|
|
15
|
+
const content = Array.isArray(result?.content) ? result.content : null
|
|
16
|
+
const contentText = content
|
|
17
|
+
? content.map((item) => (typeof item?.text === "string" ? item.text : "")).join("\n").trim()
|
|
18
|
+
: ""
|
|
19
|
+
const output =
|
|
20
|
+
contentText ||
|
|
21
|
+
(typeof result?.output === "string" ? result.output : "") ||
|
|
22
|
+
(typeof result === "string" ? result : JSON.stringify(result))
|
|
23
|
+
return content ? { output, raw: result, content } : { output, raw: result }
|
|
24
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createMetricsCollector } from "./metrics.mjs"
|
|
2
|
+
import { createTracer } from "./tracer.mjs"
|
|
3
|
+
|
|
4
|
+
let metrics = null
|
|
5
|
+
let tracer = null
|
|
6
|
+
let unsubscribes = []
|
|
7
|
+
|
|
8
|
+
export function initialize(eventBus) {
|
|
9
|
+
if (metrics) return // idempotent
|
|
10
|
+
|
|
11
|
+
metrics = createMetricsCollector()
|
|
12
|
+
tracer = createTracer()
|
|
13
|
+
|
|
14
|
+
unsubscribes.push(
|
|
15
|
+
eventBus.registerSink(async (event) => {
|
|
16
|
+
metrics.handleEvent(event)
|
|
17
|
+
tracer.handleEvent(event)
|
|
18
|
+
})
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function shutdown() {
|
|
23
|
+
for (const unsub of unsubscribes) unsub()
|
|
24
|
+
unsubscribes = []
|
|
25
|
+
metrics = null
|
|
26
|
+
tracer = null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getMetrics() {
|
|
30
|
+
return metrics ? metrics.getSnapshot() : null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getTraces() {
|
|
34
|
+
return tracer ? tracer.getTraces() : []
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function exportReport() {
|
|
38
|
+
return {
|
|
39
|
+
metrics: metrics ? metrics.getSnapshot() : null,
|
|
40
|
+
traces: tracer ? tracer.exportTraces("json") : "[]"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { EVENT_TYPES } from "../core/constants.mjs"
|
|
2
|
+
|
|
3
|
+
function createHistogram() {
|
|
4
|
+
const values = []
|
|
5
|
+
return {
|
|
6
|
+
record(v) { values.push(v) },
|
|
7
|
+
snapshot() {
|
|
8
|
+
if (values.length === 0) return { count: 0, sum: 0, min: 0, max: 0, avg: 0, p50: 0, p99: 0 }
|
|
9
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
10
|
+
const sum = sorted.reduce((s, v) => s + v, 0)
|
|
11
|
+
const count = sorted.length
|
|
12
|
+
return {
|
|
13
|
+
count,
|
|
14
|
+
sum,
|
|
15
|
+
min: sorted[0],
|
|
16
|
+
max: sorted[count - 1],
|
|
17
|
+
avg: sum / count,
|
|
18
|
+
p50: sorted[Math.max(0, Math.ceil(count * 0.5) - 1)] || 0,
|
|
19
|
+
p99: sorted[Math.max(0, Math.ceil(count * 0.99) - 1)] || 0
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
reset() { values.length = 0 }
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createMetricsCollector() {
|
|
27
|
+
const counters = new Map()
|
|
28
|
+
const histograms = new Map()
|
|
29
|
+
const turnStarts = new Map()
|
|
30
|
+
const stageStarts = new Map()
|
|
31
|
+
|
|
32
|
+
function inc(name, amount = 1) {
|
|
33
|
+
counters.set(name, (counters.get(name) || 0) + amount)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hist(name) {
|
|
37
|
+
if (!histograms.has(name)) histograms.set(name, createHistogram())
|
|
38
|
+
return histograms.get(name)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MAX_OPEN_ENTRIES = 500
|
|
42
|
+
|
|
43
|
+
function pruneStaleMap(map) {
|
|
44
|
+
if (map.size <= MAX_OPEN_ENTRIES) return
|
|
45
|
+
const cutoff = Date.now() - 30 * 60 * 1000 // 30 min
|
|
46
|
+
for (const [k, v] of map) {
|
|
47
|
+
if (v < cutoff) map.delete(k)
|
|
48
|
+
}
|
|
49
|
+
// If still over limit, drop oldest half
|
|
50
|
+
if (map.size > MAX_OPEN_ENTRIES) {
|
|
51
|
+
let toDrop = Math.floor(map.size / 2)
|
|
52
|
+
for (const k of map.keys()) {
|
|
53
|
+
if (toDrop-- <= 0) break
|
|
54
|
+
map.delete(k)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function handleEvent(event) {
|
|
60
|
+
const { type, payload, turnId, sessionId } = event
|
|
61
|
+
|
|
62
|
+
if (type === EVENT_TYPES.TURN_START) {
|
|
63
|
+
inc("turn_count")
|
|
64
|
+
if (turnId) {
|
|
65
|
+
turnStarts.set(turnId, event.timestamp)
|
|
66
|
+
pruneStaleMap(turnStarts)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (type === EVENT_TYPES.TURN_FINISH) {
|
|
71
|
+
if (turnId && turnStarts.has(turnId)) {
|
|
72
|
+
hist("turn_duration_ms").record(event.timestamp - turnStarts.get(turnId))
|
|
73
|
+
turnStarts.delete(turnId)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (type === EVENT_TYPES.TURN_ERROR) {
|
|
78
|
+
inc("error_count")
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (type === EVENT_TYPES.TOOL_START) {
|
|
82
|
+
inc("tool_call_count")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (type === EVENT_TYPES.TOOL_ERROR) {
|
|
86
|
+
inc("tool_error_count")
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (type === EVENT_TYPES.TURN_USAGE_UPDATE) {
|
|
90
|
+
if (payload?.input) inc("token_input", payload.input)
|
|
91
|
+
if (payload?.output) inc("token_output", payload.output)
|
|
92
|
+
if (payload?.cacheRead) inc("token_cache_read", payload.cacheRead)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (type === EVENT_TYPES.LONGAGENT_STAGE_STARTED) {
|
|
96
|
+
const key = payload?.stageId || sessionId
|
|
97
|
+
if (key) {
|
|
98
|
+
stageStarts.set(key, event.timestamp)
|
|
99
|
+
pruneStaleMap(stageStarts)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (type === EVENT_TYPES.LONGAGENT_STAGE_FINISHED) {
|
|
104
|
+
const key = payload?.stageId || sessionId
|
|
105
|
+
if (key && stageStarts.has(key)) {
|
|
106
|
+
hist("longagent_stage_duration_ms").record(event.timestamp - stageStarts.get(key))
|
|
107
|
+
stageStarts.delete(key)
|
|
108
|
+
}
|
|
109
|
+
if (payload?.retryCount > 0) {
|
|
110
|
+
inc("longagent_task_retries", payload.retryCount)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (type === EVENT_TYPES.LONGAGENT_GATE_CHECKED) {
|
|
115
|
+
inc("gate_check_count")
|
|
116
|
+
if (payload?.status === "pass") inc("gate_pass_count")
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getSnapshot() {
|
|
121
|
+
const counterSnapshot = new Map(counters)
|
|
122
|
+
const histogramSnapshot = new Map()
|
|
123
|
+
for (const [name, h] of histograms) {
|
|
124
|
+
histogramSnapshot.set(name, h.snapshot())
|
|
125
|
+
}
|
|
126
|
+
return { counters: counterSnapshot, histograms: histogramSnapshot }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function reset() {
|
|
130
|
+
counters.clear()
|
|
131
|
+
histograms.clear()
|
|
132
|
+
turnStarts.clear()
|
|
133
|
+
stageStarts.clear()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { handleEvent, getSnapshot, reset }
|
|
137
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { EVENT_TYPES } from "../core/constants.mjs"
|
|
2
|
+
import { randomUUID } from "node:crypto"
|
|
3
|
+
|
|
4
|
+
function newSpanId() {
|
|
5
|
+
return `span_${randomUUID().slice(0, 12)}`
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createTracer(options = {}) {
|
|
9
|
+
const maxTraces = options.maxTraces || 100
|
|
10
|
+
const maxOpenSpans = options.maxOpenSpans || 500
|
|
11
|
+
const traces = []
|
|
12
|
+
const openSpans = new Map()
|
|
13
|
+
const phaseSpan = { current: null }
|
|
14
|
+
let currentTraceId = null
|
|
15
|
+
|
|
16
|
+
function pruneOpenSpans() {
|
|
17
|
+
if (openSpans.size <= maxOpenSpans) return
|
|
18
|
+
// Close oldest spans as "expired"
|
|
19
|
+
let toDrop = Math.floor(openSpans.size / 2)
|
|
20
|
+
for (const [key, span] of openSpans) {
|
|
21
|
+
if (toDrop-- <= 0) break
|
|
22
|
+
closeSpan(span, "expired")
|
|
23
|
+
openSpans.delete(key)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function startSpan(name, attributes = {}, parentSpanId = null, timestamp = null) {
|
|
28
|
+
if (!currentTraceId) currentTraceId = `trace_${randomUUID().slice(0, 12)}`
|
|
29
|
+
const span = {
|
|
30
|
+
traceId: currentTraceId,
|
|
31
|
+
spanId: newSpanId(),
|
|
32
|
+
parentSpanId,
|
|
33
|
+
name,
|
|
34
|
+
startTime: timestamp || Date.now(),
|
|
35
|
+
endTime: null,
|
|
36
|
+
duration: null,
|
|
37
|
+
attributes,
|
|
38
|
+
status: "ok"
|
|
39
|
+
}
|
|
40
|
+
return span
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function closeSpan(span, status = "ok", timestamp = null) {
|
|
44
|
+
span.endTime = timestamp || Date.now()
|
|
45
|
+
span.duration = span.endTime - span.startTime
|
|
46
|
+
span.status = status
|
|
47
|
+
traces.push(span)
|
|
48
|
+
if (traces.length > maxTraces) traces.shift()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function handleEvent(event) {
|
|
52
|
+
const { type, payload, turnId, sessionId, timestamp } = event
|
|
53
|
+
|
|
54
|
+
if (type === EVENT_TYPES.TURN_START) {
|
|
55
|
+
const key = `turn:${turnId}`
|
|
56
|
+
if (turnId && openSpans.has(key)) {
|
|
57
|
+
closeSpan(openSpans.get(key), "error", timestamp)
|
|
58
|
+
openSpans.delete(key)
|
|
59
|
+
}
|
|
60
|
+
const span = startSpan("turn", { turnId, sessionId }, null, timestamp)
|
|
61
|
+
if (turnId) openSpans.set(key, span)
|
|
62
|
+
pruneOpenSpans()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (type === EVENT_TYPES.TURN_FINISH) {
|
|
66
|
+
const key = `turn:${turnId}`
|
|
67
|
+
const span = openSpans.get(key)
|
|
68
|
+
if (span) {
|
|
69
|
+
closeSpan(span, "ok", timestamp)
|
|
70
|
+
openSpans.delete(key)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (type === EVENT_TYPES.TURN_ERROR) {
|
|
75
|
+
const key = `turn:${turnId}`
|
|
76
|
+
const span = openSpans.get(key)
|
|
77
|
+
if (span) {
|
|
78
|
+
span.attributes.error = payload?.error || "unknown"
|
|
79
|
+
closeSpan(span, "error", timestamp)
|
|
80
|
+
openSpans.delete(key)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (type === EVENT_TYPES.LONGAGENT_STAGE_STARTED) {
|
|
85
|
+
const stageId = payload?.stageId
|
|
86
|
+
if (stageId) {
|
|
87
|
+
const span = startSpan("stage", { stageId, sessionId }, null, timestamp)
|
|
88
|
+
openSpans.set(`stage:${stageId}`, span)
|
|
89
|
+
pruneOpenSpans()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (type === EVENT_TYPES.LONGAGENT_STAGE_FINISHED) {
|
|
94
|
+
const stageId = payload?.stageId
|
|
95
|
+
const key = `stage:${stageId}`
|
|
96
|
+
const span = openSpans.get(key)
|
|
97
|
+
if (span) {
|
|
98
|
+
span.attributes.successCount = payload?.successCount
|
|
99
|
+
span.attributes.failCount = payload?.failCount
|
|
100
|
+
closeSpan(span, payload?.allSuccess ? "ok" : "error", timestamp)
|
|
101
|
+
openSpans.delete(key)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (type === EVENT_TYPES.LONGAGENT_PHASE_CHANGED) {
|
|
106
|
+
if (phaseSpan.current) {
|
|
107
|
+
closeSpan(phaseSpan.current, "ok", timestamp)
|
|
108
|
+
}
|
|
109
|
+
const span = startSpan("phase", {
|
|
110
|
+
phase: payload?.phase || payload?.newPhase,
|
|
111
|
+
sessionId
|
|
112
|
+
}, null, timestamp)
|
|
113
|
+
phaseSpan.current = span
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function getTraces() {
|
|
118
|
+
const result = [...traces]
|
|
119
|
+
if (phaseSpan.current) result.push({ ...phaseSpan.current, status: "open" })
|
|
120
|
+
return result
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function exportTraces(format = "json") {
|
|
124
|
+
const all = getTraces()
|
|
125
|
+
if (format === "json") return JSON.stringify(all, null, 2)
|
|
126
|
+
return JSON.stringify(all)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function reset() {
|
|
130
|
+
traces.length = 0
|
|
131
|
+
openSpans.clear()
|
|
132
|
+
phaseSpan.current = null
|
|
133
|
+
currentTraceId = null
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { handleEvent, getTraces, exportTraces, reset }
|
|
137
|
+
}
|