@gobing-ai/ts-ai-runner 0.3.1 → 0.3.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.
Files changed (46) hide show
  1. package/README.md +336 -32
  2. package/dist/agent-detector.d.ts +1 -0
  3. package/dist/agent-detector.d.ts.map +1 -1
  4. package/dist/agent-detector.js +13 -5
  5. package/dist/agent-spec.d.ts +6 -0
  6. package/dist/agent-spec.d.ts.map +1 -1
  7. package/dist/agent-spec.js +12 -8
  8. package/dist/ai-runner.d.ts +18 -2
  9. package/dist/ai-runner.d.ts.map +1 -1
  10. package/dist/ai-runner.js +52 -6
  11. package/dist/doctor-runner.d.ts +7 -0
  12. package/dist/doctor-runner.d.ts.map +1 -1
  13. package/dist/doctor-runner.js +69 -15
  14. package/dist/events.d.ts +38 -0
  15. package/dist/events.d.ts.map +1 -0
  16. package/dist/events.js +0 -0
  17. package/dist/identity.d.ts +3 -0
  18. package/dist/identity.d.ts.map +1 -1
  19. package/dist/identity.js +2 -0
  20. package/dist/index.d.ts +2 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -1
  23. package/dist/messages.d.ts +4 -0
  24. package/dist/messages.d.ts.map +1 -0
  25. package/dist/messages.js +4 -0
  26. package/dist/team-agent-process.d.ts +12 -4
  27. package/dist/team-agent-process.d.ts.map +1 -1
  28. package/dist/team-agent-process.js +31 -19
  29. package/dist/team-orchestrator.d.ts +13 -8
  30. package/dist/team-orchestrator.d.ts.map +1 -1
  31. package/dist/team-orchestrator.js +32 -25
  32. package/package.json +4 -4
  33. package/src/agent-detector.ts +14 -5
  34. package/src/agent-spec.ts +20 -8
  35. package/src/ai-runner.ts +75 -13
  36. package/src/doctor-runner.ts +77 -16
  37. package/src/events.ts +25 -0
  38. package/src/identity.ts +3 -0
  39. package/src/index.ts +2 -1
  40. package/src/messages.ts +6 -0
  41. package/src/team-agent-process.ts +36 -21
  42. package/src/team-orchestrator.ts +36 -25
  43. package/dist/message-service.d.ts +0 -13
  44. package/dist/message-service.d.ts.map +0 -1
  45. package/dist/message-service.js +0 -27
  46. package/src/message-service.ts +0 -33
package/src/identity.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { BunSyncProcessExecutor, type SyncProcessExecutor } from '@gobing-ai/ts-runtime';
2
2
 
3
+ /** Context used to construct the identity preamble injected into agent prompts. */
3
4
  export interface IdentityContext {
4
5
  agentId: string;
5
6
  agentType: string;
@@ -14,6 +15,7 @@ export interface IdentityContext {
14
15
  guardrails?: string[];
15
16
  }
16
17
 
18
+ /** Build a human-readable identity preamble string that describes the agent, its task, peers, guardrails, and git context. */
17
19
  export function buildIdentityPreamble(ctx: IdentityContext): string {
18
20
  const sections: string[] = [
19
21
  `You are agent \`${ctx.agentId}\` (${ctx.agentType}) in workspace \`${ctx.workspace}\`.`,
@@ -62,6 +64,7 @@ export function buildIdentityPreamble(ctx: IdentityContext): string {
62
64
  return `${sections.join('\n\n')}\n`;
63
65
  }
64
66
 
67
+ /** Query git for the current branch name and dirty file count in `workspacePath`. Returns a pre-formatted "Git context" block, or `null` if git is unavailable or the directory is not a repo. */
65
68
  export function getGitContext(
66
69
  workspacePath: string,
67
70
  executor: SyncProcessExecutor = new BunSyncProcessExecutor(),
package/src/index.ts CHANGED
@@ -3,8 +3,9 @@ export * from './agent-spec';
3
3
  export * from './agents/shims';
4
4
  export * from './ai-runner';
5
5
  export * from './doctor-runner';
6
+ export * from './events';
6
7
  export * from './identity';
7
- export * from './message-service';
8
+ export * from './messages';
8
9
  export * from './slash-command';
9
10
  export * from './team-agent-process';
10
11
  export * from './team-orchestrator';
@@ -0,0 +1,6 @@
1
+ import type { InboxMessage } from '@gobing-ai/ts-db/inbox';
2
+
3
+ /** Renders an inbox message into the line injected into an agent's stdin. */
4
+ export function formatMessage(msg: InboxMessage): string {
5
+ return `[task from=${msg.fromId ?? 'operator'} id=${msg.id}] ${msg.body}`;
6
+ }
@@ -1,25 +1,31 @@
1
1
  import { Buffer } from 'node:buffer';
2
- import { BunPipeProcessSpawner, type PipeProcess, type PipeProcessSpawner } from '@gobing-ai/ts-runtime';
2
+ import { getLogger, type Logger } from '@gobing-ai/ts-infra';
3
+ import { type PipeProcess, ProcessExecutor } from '@gobing-ai/ts-runtime';
3
4
  import type { AgentSpec } from './agent-spec';
4
- import { buildIdentityPreamble } from './identity';
5
5
 
6
+ /** Options for spawning a team agent subprocess. */
6
7
  export interface AgentProcessOptions {
7
8
  spec: AgentSpec;
8
9
  command: string[];
9
10
  env?: Record<string, string>;
10
11
  cwd?: string;
11
- processSpawner?: PipeProcessSpawner;
12
+ processExecutor?: ProcessExecutor;
13
+ logger?: Logger;
12
14
  }
13
15
 
14
16
  type ProcessStatus = 'running' | 'stopped' | 'errored';
15
17
 
18
+ /**
19
+ * Manages the lifecycle of a single agent subprocess — start, stop, message send, and stdout/stderr subscription.
20
+ * The identity preamble is built by `TeamOrchestrator` and baked into `command` before the process is constructed.
21
+ */
16
22
  export class TeamAgentProcess {
17
23
  readonly agentId: string;
18
- readonly identityPreamble: string;
19
24
  private readonly command: string[];
20
25
  private readonly env: Record<string, string> | undefined;
21
26
  private readonly cwd: string | undefined;
22
- private readonly processSpawner: PipeProcessSpawner;
27
+ private readonly processExecutor: ProcessExecutor;
28
+ private readonly logger: Logger;
23
29
  private subprocess: PipeProcess | null = null;
24
30
  private status: ProcessStatus = 'stopped';
25
31
  private exitCode: number | null = null;
@@ -27,27 +33,21 @@ export class TeamAgentProcess {
27
33
 
28
34
  constructor(options: AgentProcessOptions) {
29
35
  this.agentId = options.spec.id;
30
- this.identityPreamble = buildIdentityPreamble({
31
- agentId: options.spec.id,
32
- agentType: options.spec.type,
33
- workspace: options.spec.workspace,
34
- purpose: options.spec.purpose,
35
- systemPrompt:
36
- typeof options.spec.config.systemPrompt === 'string' ? options.spec.config.systemPrompt : undefined,
37
- });
38
36
  this.command = options.command;
39
37
  this.env = options.env;
40
38
  this.cwd = options.cwd ?? options.spec.workspace;
41
- this.processSpawner = options.processSpawner ?? new BunPipeProcessSpawner();
39
+ this.processExecutor = options.processExecutor ?? new ProcessExecutor();
40
+ this.logger = options.logger ?? getLogger('team-agent');
42
41
  }
43
42
 
44
43
  async start(): Promise<void> {
45
44
  if (this.status === 'running') return;
46
45
  const [command, ...args] = this.command;
47
46
  if (command === undefined) throw new Error(`${this.agentId}: command must not be empty`);
48
- this.subprocess = this.processSpawner.spawn({
47
+ this.subprocess = this.processExecutor.runStreaming({
49
48
  command,
50
49
  args,
50
+ label: `team-agent.${this.agentId}`,
51
51
  ...(this.cwd !== undefined ? { cwd: this.cwd } : {}),
52
52
  ...(this.env !== undefined ? { env: this.env } : {}),
53
53
  });
@@ -69,11 +69,13 @@ export class TeamAgentProcess {
69
69
  }
70
70
  try {
71
71
  process.endStdin();
72
- } catch {
73
- // Process may have already closed stdin.
72
+ } catch (error) {
73
+ this.warn('stdin close failed', 'stop.endStdin', error);
74
74
  }
75
75
  process.kill('SIGTERM');
76
- const timeout = Bun.sleep(5000).then(() => 'timeout' as const);
76
+ const timeout = new Promise<'timeout'>((resolve) => {
77
+ setTimeout(() => resolve('timeout'), 5000);
78
+ });
77
79
  const result = await Promise.race([process.exited, timeout]);
78
80
  if (result === 'timeout') {
79
81
  process.kill('SIGKILL');
@@ -86,11 +88,15 @@ export class TeamAgentProcess {
86
88
  }
87
89
 
88
90
  async send(message: string): Promise<{ ok: boolean }> {
89
- if (this.status !== 'running' || this.subprocess === null) return { ok: false };
91
+ if (this.status !== 'running' || this.subprocess === null) {
92
+ this.warn('send skipped because process is not running', 'send.notRunning');
93
+ return { ok: false };
94
+ }
90
95
  try {
91
96
  this.subprocess.writeStdin(`${message}\n`);
92
97
  return { ok: true };
93
- } catch {
98
+ } catch (error) {
99
+ this.warn('stdin write failed', 'send.writeStdin', error);
94
100
  this.status = 'errored';
95
101
  return { ok: false };
96
102
  }
@@ -124,10 +130,19 @@ export class TeamAgentProcess {
124
130
  const buffer = Buffer.from(chunk.value);
125
131
  for (const subscriber of this.subscribers) subscriber(buffer);
126
132
  }
127
- } catch {
133
+ } catch (error) {
134
+ this.warn('stream pipe failed', 'pipe', error);
128
135
  if (this.status === 'running') this.status = 'errored';
129
136
  } finally {
130
137
  reader.releaseLock();
131
138
  }
132
139
  }
140
+
141
+ private warn(message: string, op: string, error?: unknown): void {
142
+ this.logger.warn(message, {
143
+ agentId: this.agentId,
144
+ op,
145
+ ...(error !== undefined ? { error: error instanceof Error ? error.message : String(error) } : {}),
146
+ });
147
+ }
133
148
  }
@@ -1,30 +1,38 @@
1
+ import type { InboxMessageDao } from '@gobing-ai/ts-db/inbox';
2
+ import { EventBus } from '@gobing-ai/ts-infra';
1
3
  import type { AgentSpec } from './agent-spec';
2
4
  import { loadAgentSpecs } from './agent-spec';
3
5
  import { type AgentName, getAgentShim, isAgentName } from './agents/shims';
6
+ import type { AgentEvents } from './events';
4
7
  import { buildIdentityPreamble } from './identity';
5
- import { MessageService } from './message-service';
8
+ import { formatMessage } from './messages';
6
9
  import { TeamAgentProcess } from './team-agent-process';
7
10
 
8
- type TeamEvent = 'agent.started' | 'agent.stopped' | 'message.sent';
9
- type TeamListener = (payload: unknown) => void;
10
11
  type AgentProcessFactory = (options: ConstructorParameters<typeof TeamAgentProcess>[0]) => TeamAgentProcess;
11
12
 
13
+ /** Configuration options for `TeamOrchestrator`. */
12
14
  export interface TeamOrchestratorOptions {
13
15
  processFactory?: AgentProcessFactory;
16
+ events?: EventBus<AgentEvents>;
14
17
  }
15
18
 
19
+ /**
20
+ * Orchestrates a team of AI agents — loads specs, starts/stops agent processes, routes messages between them,
21
+ * and emits lifecycle events. Agents in the same workspace see each other as peers.
22
+ */
16
23
  export class TeamOrchestrator {
17
24
  private specs: AgentSpec[] = [];
18
25
  private readonly running = new Map<string, TeamAgentProcess>();
19
- private readonly listeners = new Map<TeamEvent, Set<TeamListener>>();
20
26
  private readonly processFactory: AgentProcessFactory;
27
+ private readonly events: EventBus<AgentEvents>;
21
28
 
22
29
  constructor(
23
30
  private readonly configDir: string,
24
- private readonly messageService: MessageService,
31
+ private readonly inbox: InboxMessageDao,
25
32
  options: TeamOrchestratorOptions = {},
26
33
  ) {
27
34
  this.processFactory = options.processFactory ?? ((processOptions) => new TeamAgentProcess(processOptions));
35
+ this.events = options.events ?? new EventBus<AgentEvents>();
28
36
  }
29
37
 
30
38
  loadSpecs(): AgentSpec[] {
@@ -67,7 +75,11 @@ export class TeamOrchestrator {
67
75
  await process.start();
68
76
  this.running.set(id, process);
69
77
  await this.injectPendingMessages(process);
70
- this.emit('agent.started', { id });
78
+ void this.events.emit('agent.started', {
79
+ agentId: spec.id,
80
+ agentType: spec.type,
81
+ pid: process.getPid(),
82
+ });
71
83
  return process;
72
84
  }
73
85
 
@@ -76,7 +88,7 @@ export class TeamOrchestrator {
76
88
  if (process === undefined) return;
77
89
  await process.stop();
78
90
  this.running.delete(id);
79
- this.emit('agent.stopped', { id });
91
+ void this.events.emit('agent.stopped', { agentId: id, exitCode: process.getExitCode() });
80
92
  }
81
93
 
82
94
  async restartAgent(id: string): Promise<TeamAgentProcess> {
@@ -85,10 +97,10 @@ export class TeamOrchestrator {
85
97
  }
86
98
 
87
99
  async sendMessage(fromId: string | null, toId: string, body: string, inReplyTo?: string): Promise<string> {
88
- const msgId = await this.messageService.enqueue(fromId, toId, body, inReplyTo);
100
+ const msgId = await this.inbox.enqueue(fromId, toId, body, inReplyTo);
89
101
  const process = this.running.get(toId);
90
- if (process !== undefined) await this.flushInbox(process, 'live stdin injection failed');
91
- this.emit('message.sent', { id: msgId, fromId, toId });
102
+ const ok = process !== undefined ? await this.flushInbox(process, 'live stdin injection failed') : false;
103
+ void this.events.emit('agent.message.sent', { agentId: toId, ok });
92
104
  return msgId;
93
105
  }
94
106
 
@@ -111,11 +123,9 @@ export class TeamOrchestrator {
111
123
  await Promise.all([...this.running.keys()].map((id) => this.stopAgent(id)));
112
124
  }
113
125
 
114
- on(event: TeamEvent, listener: TeamListener): () => void {
115
- const listeners = this.listeners.get(event) ?? new Set<TeamListener>();
116
- listeners.add(listener);
117
- this.listeners.set(event, listeners);
118
- return () => listeners.delete(listener);
126
+ on<K extends keyof AgentEvents>(event: K, listener: AgentEvents[K]): () => void {
127
+ this.events.on(event, listener);
128
+ return () => this.events.off(event, listener);
119
129
  }
120
130
 
121
131
  private requireSpec(id: string): AgentSpec {
@@ -129,20 +139,21 @@ export class TeamOrchestrator {
129
139
  return type;
130
140
  }
131
141
 
132
- private injectPendingMessages(process: TeamAgentProcess): Promise<void> {
142
+ private async injectPendingMessages(process: TeamAgentProcess): Promise<boolean> {
133
143
  return this.flushInbox(process, 'startup stdin injection failed');
134
144
  }
135
145
 
136
- private async flushInbox(process: TeamAgentProcess, failLabel: string): Promise<void> {
137
- const messages = await this.messageService.drain(process.agentId);
146
+ private async flushInbox(process: TeamAgentProcess, failLabel: string): Promise<boolean> {
147
+ const messages = await this.inbox.drainPending(process.agentId);
148
+ let ok = true;
138
149
  for (const message of messages) {
139
- const result = await process.send(MessageService.formatMessage(message));
140
- if (result.ok) await this.messageService.deliver(message.id);
141
- else await this.messageService.fail(message.id, failLabel);
150
+ const result = await process.send(formatMessage(message));
151
+ if (result.ok) await this.inbox.markDelivered(message.id);
152
+ else {
153
+ ok = false;
154
+ await this.inbox.markFailed(message.id, failLabel);
155
+ }
142
156
  }
143
- }
144
-
145
- private emit(event: TeamEvent, payload: unknown): void {
146
- for (const listener of this.listeners.get(event) ?? []) listener(payload);
157
+ return ok;
147
158
  }
148
159
  }
@@ -1,13 +0,0 @@
1
- import type { InboxMessage, InboxMessageDao } from '@gobing-ai/ts-db/inbox';
2
- export declare class MessageService {
3
- private readonly dao;
4
- constructor(dao: InboxMessageDao);
5
- enqueue(fromId: string | null, toId: string, body: string, inReplyTo?: string): Promise<string>;
6
- drain(toId: string): Promise<InboxMessage[]>;
7
- deliver(msgId: string): Promise<void>;
8
- fail(msgId: string, error: string): Promise<void>;
9
- inbox(toId: string, limit?: number, offset?: number): Promise<InboxMessage[]>;
10
- countPending(toId: string): Promise<number>;
11
- static formatMessage(msg: InboxMessage): string;
12
- }
13
- //# sourceMappingURL=message-service.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"message-service.d.ts","sourceRoot":"","sources":["../src/message-service.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAE5E,qBAAa,cAAc;IACX,OAAO,CAAC,QAAQ,CAAC,GAAG;gBAAH,GAAG,EAAE,eAAe;IAEjD,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAI/F,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IAI5C,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrC,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIjD,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,EAAE,CAAC;IAI7E,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAI3C,MAAM,CAAC,aAAa,CAAC,GAAG,EAAE,YAAY,GAAG,MAAM;CAGlD"}
@@ -1,27 +0,0 @@
1
- export class MessageService {
2
- dao;
3
- constructor(dao) {
4
- this.dao = dao;
5
- }
6
- enqueue(fromId, toId, body, inReplyTo) {
7
- return this.dao.enqueue(fromId, toId, body, inReplyTo);
8
- }
9
- drain(toId) {
10
- return this.dao.drainPending(toId);
11
- }
12
- deliver(msgId) {
13
- return this.dao.markDelivered(msgId);
14
- }
15
- fail(msgId, error) {
16
- return this.dao.markFailed(msgId, error);
17
- }
18
- inbox(toId, limit, offset) {
19
- return this.dao.inbox(toId, limit, offset);
20
- }
21
- countPending(toId) {
22
- return this.dao.countPending(toId);
23
- }
24
- static formatMessage(msg) {
25
- return `[task from=${msg.fromId ?? 'operator'} id=${msg.id}] ${msg.body}`;
26
- }
27
- }
@@ -1,33 +0,0 @@
1
- import type { InboxMessage, InboxMessageDao } from '@gobing-ai/ts-db/inbox';
2
-
3
- export class MessageService {
4
- constructor(private readonly dao: InboxMessageDao) {}
5
-
6
- enqueue(fromId: string | null, toId: string, body: string, inReplyTo?: string): Promise<string> {
7
- return this.dao.enqueue(fromId, toId, body, inReplyTo);
8
- }
9
-
10
- drain(toId: string): Promise<InboxMessage[]> {
11
- return this.dao.drainPending(toId);
12
- }
13
-
14
- deliver(msgId: string): Promise<void> {
15
- return this.dao.markDelivered(msgId);
16
- }
17
-
18
- fail(msgId: string, error: string): Promise<void> {
19
- return this.dao.markFailed(msgId, error);
20
- }
21
-
22
- inbox(toId: string, limit?: number, offset?: number): Promise<InboxMessage[]> {
23
- return this.dao.inbox(toId, limit, offset);
24
- }
25
-
26
- countPending(toId: string): Promise<number> {
27
- return this.dao.countPending(toId);
28
- }
29
-
30
- static formatMessage(msg: InboxMessage): string {
31
- return `[task from=${msg.fromId ?? 'operator'} id=${msg.id}] ${msg.body}`;
32
- }
33
- }