agents-dojo 0.1.2 → 0.1.3

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.
@@ -4,6 +4,26 @@ import { a2aToContentBlocks } from './part-mapper.js';
4
4
  import { runClaude } from './claude-bridge.js';
5
5
  import { createTranslator } from './event-translator.js';
6
6
  import { recordTaskStart, recordTaskEnd } from './metrics.js';
7
+ import { logConversation, AgentConversationLogger } from './agent-logger.js';
8
+ // ── Global concurrency limiter ─────────────────────────────
9
+ // Prevents CPU overload by limiting simultaneous Claude SDK processes.
10
+ const MAX_CONCURRENT = 3;
11
+ const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes per task
12
+ let running = 0;
13
+ const waitQueue = [];
14
+ function acquireSlot() {
15
+ if (running < MAX_CONCURRENT) {
16
+ running++;
17
+ return Promise.resolve();
18
+ }
19
+ return new Promise((resolve) => waitQueue.push(() => { running++; resolve(); }));
20
+ }
21
+ function releaseSlot() {
22
+ running--;
23
+ const next = waitQueue.shift();
24
+ if (next)
25
+ next();
26
+ }
7
27
  export class DojoAgentExecutor {
8
28
  agent;
9
29
  options;
@@ -22,7 +42,6 @@ export class DojoAgentExecutor {
22
42
  this.controllers.delete(taskId);
23
43
  this.contextIds.delete(taskId);
24
44
  // Publish canceled state to the bus so the client sees the transition.
25
- // Guard against publishing if the bus is no longer accepting (best-effort).
26
45
  try {
27
46
  eventBus.publish({
28
47
  kind: 'status-update',
@@ -39,9 +58,21 @@ export class DojoAgentExecutor {
39
58
  catch {
40
59
  // ignore — the SDK subprocess may already be torn down
41
60
  }
61
+ // Also notify monitor
62
+ this.options.monitorBus?.emit({ type: 'task_status', taskId, state: 'canceled' });
42
63
  };
43
64
  async execute(requestContext, eventBus) {
44
65
  const { taskId, contextId, userMessage } = requestContext;
66
+ // Wait for a concurrency slot
67
+ await acquireSlot();
68
+ // Set up task timeout
69
+ const timeoutTimer = setTimeout(() => {
70
+ const ctrl = this.controllers.get(taskId);
71
+ if (ctrl) {
72
+ console.warn(`[agents-dojo] Task ${taskId} timed out after ${DEFAULT_TIMEOUT_MS / 1000}s, aborting`);
73
+ ctrl.abort();
74
+ }
75
+ }, DEFAULT_TIMEOUT_MS);
45
76
  recordTaskStart();
46
77
  // Publish the initial task snapshot. The A2A SDK's ResultManager only
47
78
  // initializes its currentTask from a `kind: 'task'` event; without it,
@@ -58,15 +89,21 @@ export class DojoAgentExecutor {
58
89
  history: [userMessage],
59
90
  artifacts: [],
60
91
  });
61
- // Emit task_created to monitor bus. A2A has no real "sender" concept beyond
62
- // user/agent, so we use the convention from: 'user' for any inbound client.
92
+ // Emit task_created to monitor bus.
93
+ const preview = extractPreview(userMessage.parts);
63
94
  this.options.monitorBus?.emit({
64
95
  type: 'task_created',
65
96
  taskId,
66
97
  contextId,
67
98
  from: 'user',
68
99
  to: this.agent.manifest.id,
69
- preview: extractPreview(userMessage.parts),
100
+ preview,
101
+ });
102
+ // Also emit submitted state to monitor
103
+ this.options.monitorBus?.emit({
104
+ type: 'task_status',
105
+ taskId,
106
+ state: 'submitted',
70
107
  });
71
108
  // 1. Extract text content from A2A message
72
109
  const parts = userMessage.parts;
@@ -86,12 +123,28 @@ export class DojoAgentExecutor {
86
123
  this.controllers.set(taskId, controller);
87
124
  this.contextIds.set(taskId, contextId);
88
125
  // 4. Run Claude (iterate events)
126
+ const startTime = Date.now();
127
+ let finalResponse = '';
128
+ let taskState = 'completed';
129
+ // Full SDK conversation logger — captures every message for transparency
130
+ const sdkLogger = new AgentConversationLogger(this.agent.agentDir, this.agent.manifest.id, taskId);
89
131
  try {
90
132
  for await (const sdkMsg of runClaude({
91
133
  agent: this.agent,
92
134
  contentBlocks,
93
135
  contextId,
94
- onEvent: (m) => translator.onSdkEvent(m),
136
+ onEvent: (m) => {
137
+ translator.onSdkEvent(m);
138
+ sdkLogger.onSdkMessage(m);
139
+ // Capture final text for summary log
140
+ const msg = m;
141
+ if (msg.type === 'assistant' && msg.message?.content) {
142
+ for (const block of msg.message.content) {
143
+ if (block.type === 'text')
144
+ finalResponse = block.text;
145
+ }
146
+ }
147
+ },
95
148
  abortController: controller,
96
149
  })) {
97
150
  // events are published via onEvent
@@ -100,13 +153,11 @@ export class DojoAgentExecutor {
100
153
  }
101
154
  catch (err) {
102
155
  recordTaskEnd(false);
103
- // If the controller was aborted, the SDK throws an AbortError. We don't
104
- // want to report it as a generic failure — cancelTask already published
105
- // the canceled state. Suppress the publish in that case.
156
+ taskState = 'failed';
106
157
  const isAbort = controller.signal.aborted || (err instanceof Error && /abort/i.test(err.message));
107
158
  if (!isAbort) {
108
- // SDK subprocess crash, network failure, etc.
109
159
  const reason = err instanceof Error ? err.message : String(err);
160
+ finalResponse = `Error: ${reason}`;
110
161
  eventBus.publish({
111
162
  kind: 'status-update',
112
163
  taskId,
@@ -129,8 +180,22 @@ export class DojoAgentExecutor {
129
180
  }
130
181
  }
131
182
  finally {
183
+ clearTimeout(timeoutTimer);
184
+ releaseSlot();
132
185
  this.controllers.delete(taskId);
133
186
  this.contextIds.delete(taskId);
187
+ // Flush full SDK conversation log (all LLM interactions)
188
+ sdkLogger.flush(taskState, Date.now() - startTime);
189
+ // Also save summary log
190
+ logConversation(this.agent.agentDir, {
191
+ taskId,
192
+ agentId: this.agent.manifest.id,
193
+ timestamp: new Date().toISOString(),
194
+ userMessage: extractPreview(userMessage.parts),
195
+ agentResponse: finalResponse.slice(0, 500),
196
+ state: taskState,
197
+ durationMs: Date.now() - startTime,
198
+ });
134
199
  }
135
200
  }
136
201
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A logger that captures every SDK message for a single task.
3
+ * Create one per task, call .onSdkMessage() for each message,
4
+ * then .flush() when the task completes.
5
+ */
6
+ export declare class AgentConversationLogger {
7
+ private agentDir;
8
+ private agentId;
9
+ private taskId;
10
+ private messages;
11
+ constructor(agentDir: string, agentId: string, taskId: string);
12
+ /** Record a raw SDK message (assistant, user, result, etc.) */
13
+ onSdkMessage(msg: unknown): void;
14
+ /** Write all captured messages to the agent's log file. */
15
+ flush(state: 'completed' | 'failed' | 'canceled', durationMs: number): void;
16
+ }
17
+ export interface ConversationEntry {
18
+ taskId: string;
19
+ agentId: string;
20
+ timestamp: string;
21
+ userMessage: string;
22
+ agentResponse: string;
23
+ state: 'completed' | 'failed';
24
+ durationMs: number;
25
+ }
26
+ export declare function logConversation(agentDir: string, entry: ConversationEntry): void;
@@ -0,0 +1,63 @@
1
+ // src/agent-logger.ts
2
+ // Saves the complete LLM conversation (all SDK messages) for each agent task.
3
+ import { appendFileSync, mkdirSync } from 'fs';
4
+ import { join } from 'path';
5
+ function todayStr() {
6
+ return new Date().toISOString().slice(0, 10);
7
+ }
8
+ /**
9
+ * A logger that captures every SDK message for a single task.
10
+ * Create one per task, call .onSdkMessage() for each message,
11
+ * then .flush() when the task completes.
12
+ */
13
+ export class AgentConversationLogger {
14
+ agentDir;
15
+ agentId;
16
+ taskId;
17
+ messages = [];
18
+ constructor(agentDir, agentId, taskId) {
19
+ this.agentDir = agentDir;
20
+ this.agentId = agentId;
21
+ this.taskId = taskId;
22
+ }
23
+ /** Record a raw SDK message (assistant, user, result, etc.) */
24
+ onSdkMessage(msg) {
25
+ this.messages.push({
26
+ timestamp: new Date().toISOString(),
27
+ type: msg?.type ?? 'unknown',
28
+ data: msg,
29
+ });
30
+ }
31
+ /** Write all captured messages to the agent's log file. */
32
+ flush(state, durationMs) {
33
+ const logDir = join(this.agentDir, 'log');
34
+ mkdirSync(logDir, { recursive: true });
35
+ const file = join(logDir, `${todayStr()}.jsonl`);
36
+ const entry = {
37
+ taskId: this.taskId,
38
+ agentId: this.agentId,
39
+ timestamp: new Date().toISOString(),
40
+ state,
41
+ durationMs,
42
+ messageCount: this.messages.length,
43
+ messages: this.messages,
44
+ };
45
+ try {
46
+ appendFileSync(file, JSON.stringify(entry) + '\n');
47
+ }
48
+ catch (err) {
49
+ console.error(`[agent-logger] Failed to write to ${file}:`, err);
50
+ }
51
+ }
52
+ }
53
+ export function logConversation(agentDir, entry) {
54
+ const logDir = join(agentDir, 'log');
55
+ mkdirSync(logDir, { recursive: true });
56
+ const file = join(logDir, `${todayStr()}.jsonl`);
57
+ try {
58
+ appendFileSync(file, JSON.stringify(entry) + '\n');
59
+ }
60
+ catch (err) {
61
+ console.error(`[agent-logger] Failed to write:`, err);
62
+ }
63
+ }
@@ -0,0 +1,26 @@
1
+ import type { MonitorBus } from './monitor-bus.js';
2
+ /**
3
+ * A single chat message in the conversation log.
4
+ */
5
+ export interface ChatMessage {
6
+ timestamp: string;
7
+ taskId: string;
8
+ from: string;
9
+ to: string;
10
+ content: string;
11
+ direction: 'incoming' | 'outgoing';
12
+ }
13
+ /**
14
+ * Subscribe to monitor bus and save chat messages (not raw events).
15
+ * Only task_created (incoming message) and task_status with working/completed
16
+ * (agent response) are saved as chat records.
17
+ */
18
+ export declare function startMonitorLogger(bus: MonitorBus, logDir: string): void;
19
+ /**
20
+ * Read chat messages from logs.
21
+ */
22
+ export declare function readLogs(logDir: string, date?: string): ChatMessage[];
23
+ /**
24
+ * List available log dates.
25
+ */
26
+ export declare function listLogDates(logDir: string): string[];
@@ -0,0 +1,123 @@
1
+ // src/log-writer.ts
2
+ // Saves agent conversations (who said what to whom) as chat-style records.
3
+ import { appendFileSync, mkdirSync, existsSync, readdirSync, readFileSync } from 'fs';
4
+ import { join } from 'path';
5
+ function todayStr() {
6
+ return new Date().toISOString().slice(0, 10);
7
+ }
8
+ /**
9
+ * Subscribe to monitor bus and save chat messages (not raw events).
10
+ * Only task_created (incoming message) and task_status with working/completed
11
+ * (agent response) are saved as chat records.
12
+ */
13
+ export function startMonitorLogger(bus, logDir) {
14
+ mkdirSync(logDir, { recursive: true });
15
+ // Track taskId → agent mapping for responses
16
+ const taskAgents = new Map();
17
+ // Track which agents are currently working (for detecting peer calls)
18
+ const workingAgents = new Set();
19
+ bus.subscribe((event) => {
20
+ const file = join(logDir, `${todayStr()}.jsonl`);
21
+ if (event.type === 'task_created') {
22
+ // Detect agent-to-agent call: if another agent is working, it's likely the caller
23
+ let from = event.from;
24
+ if (from === 'user') {
25
+ for (const agentId of workingAgents) {
26
+ if (agentId !== event.to) {
27
+ from = agentId;
28
+ break;
29
+ }
30
+ }
31
+ }
32
+ taskAgents.set(event.taskId, { from, to: event.to });
33
+ const msg = {
34
+ timestamp: new Date().toISOString(),
35
+ taskId: event.taskId,
36
+ from,
37
+ to: event.to,
38
+ content: event.preview,
39
+ direction: 'incoming',
40
+ };
41
+ writeLine(file, msg);
42
+ }
43
+ // Track working agents from tool_call events too
44
+ if (event.type === 'tool_call_start') {
45
+ const targetId = [...taskAgents.entries()].find(([tid]) => tid === event.taskId)?.[1]?.to;
46
+ if (targetId)
47
+ workingAgents.add(targetId);
48
+ }
49
+ if (event.type === 'task_status') {
50
+ // Track working agents
51
+ const targetId = [...taskAgents.entries()].find(([tid]) => tid === event.taskId)?.[1]?.to;
52
+ if (targetId) {
53
+ if (event.state === 'working')
54
+ workingAgents.add(targetId);
55
+ else if (event.state === 'completed' || event.state === 'failed' || event.state === 'canceled')
56
+ workingAgents.delete(targetId);
57
+ }
58
+ if (event.message) {
59
+ const mapping = taskAgents.get(event.taskId);
60
+ if (mapping) {
61
+ const msg = {
62
+ timestamp: new Date().toISOString(),
63
+ taskId: event.taskId,
64
+ from: mapping.to, // agent is responding
65
+ to: mapping.from, // back to the caller
66
+ content: event.message,
67
+ direction: 'outgoing',
68
+ };
69
+ writeLine(file, msg);
70
+ }
71
+ }
72
+ // Clean up on terminal states
73
+ const terminalStates = ['completed', 'failed', 'canceled', 'rejected'];
74
+ if (terminalStates.includes(event.state)) {
75
+ taskAgents.delete(event.taskId);
76
+ }
77
+ }
78
+ });
79
+ }
80
+ function writeLine(file, msg) {
81
+ try {
82
+ appendFileSync(file, JSON.stringify(msg) + '\n');
83
+ }
84
+ catch (err) {
85
+ console.error('[log-writer] Failed to write:', err);
86
+ }
87
+ }
88
+ /**
89
+ * Read chat messages from logs.
90
+ */
91
+ export function readLogs(logDir, date) {
92
+ if (!existsSync(logDir))
93
+ return [];
94
+ const files = date
95
+ ? [`${date}.jsonl`]
96
+ : readdirSync(logDir).filter((f) => f.endsWith('.jsonl')).sort().reverse();
97
+ const entries = [];
98
+ for (const file of files) {
99
+ const path = join(logDir, file);
100
+ if (!existsSync(path))
101
+ continue;
102
+ const lines = readFileSync(path, 'utf-8').split('\n').filter(Boolean);
103
+ for (const line of lines) {
104
+ try {
105
+ entries.push(JSON.parse(line));
106
+ }
107
+ catch { /* skip */ }
108
+ }
109
+ }
110
+ return entries;
111
+ }
112
+ /**
113
+ * List available log dates.
114
+ */
115
+ export function listLogDates(logDir) {
116
+ if (!existsSync(logDir))
117
+ return [];
118
+ return readdirSync(logDir)
119
+ .filter((f) => f.endsWith('.jsonl'))
120
+ .map((f) => f.replace('.jsonl', ''))
121
+ .sort()
122
+ .reverse();
123
+ }
package/dist/server.js CHANGED
@@ -1,13 +1,17 @@
1
1
  import { createServer as createHttpServer } from 'http';
2
+ import { resolve, join } from 'path';
2
3
  import { AgentRegistry } from './agent-registry.js';
3
4
  import { createA2AServer } from './a2a-server.js';
4
5
  import { createReloadApi } from './reload-api.js';
5
6
  import { createMetricsRouter, setAgentsLoaded } from './metrics.js';
6
7
  import { createMonitorBus } from './monitor-bus.js';
7
8
  import { createMonitorWs } from './monitor-ws.js';
9
+ import { startMonitorLogger, readLogs, listLogDates } from './log-writer.js';
8
10
  export async function createServer(opts) {
9
- // Create bus FIRST so handlers can reference it
10
11
  const bus = createMonitorBus();
12
+ // Start monitor event logger
13
+ const logDir = join(resolve(opts.agentsDir), '..', 'monitor-logs');
14
+ startMonitorLogger(bus, logDir);
11
15
  const registry = new AgentRegistry(opts.agentsDir);
12
16
  registry.load();
13
17
  setAgentsLoaded(registry.list().length);
@@ -18,11 +22,24 @@ export async function createServer(opts) {
18
22
  registry.on('agent_reloaded', (e) => bus.emit({ type: 'agent_reloaded', ...e }));
19
23
  const a2a = createA2AServer({ registry, singleAgent: opts.singleAgent, port: opts.port, monitorBus: bus });
20
24
  const app = a2a.app;
25
+ // CORS for Monitor GUI (runs on a different port)
26
+ app.use('/logs', (_req, res, next) => {
27
+ res.header('Access-Control-Allow-Origin', '*');
28
+ res.header('Access-Control-Allow-Methods', 'GET');
29
+ next();
30
+ });
21
31
  app.use('/agents', createReloadApi(registry));
22
32
  app.use('/metrics', createMetricsRouter());
33
+ // Logs REST API
34
+ app.get('/logs', (_req, res) => {
35
+ const date = _req.query.date;
36
+ res.json({ entries: readLogs(logDir, date) });
37
+ });
38
+ app.get('/logs/dates', (_req, res) => {
39
+ res.json({ dates: listLogDates(logDir) });
40
+ });
23
41
  const httpServer = createHttpServer(app);
24
42
  if (opts.monitorPort) {
25
- // Bind monitor on a separate server
26
43
  const monitorHttp = createHttpServer();
27
44
  createMonitorWs({ server: monitorHttp, bus, path: '/monitor', registry });
28
45
  await new Promise((r) => monitorHttp.listen(opts.monitorPort, r));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-dojo",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "A2A-compatible Agent framework built on Claude Code SDK",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",