@gobing-ai/ts-ai-runner 0.2.8 → 0.3.0

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.
@@ -0,0 +1,86 @@
1
+ import { BunSyncProcessExecutor, type SyncProcessExecutor } from '@gobing-ai/ts-runtime';
2
+
3
+ export interface IdentityContext {
4
+ agentId: string;
5
+ agentType: string;
6
+ workspace: string;
7
+ purpose?: string;
8
+ taskId?: string;
9
+ taskTitle?: string;
10
+ systemPrompt?: string;
11
+ peers?: Array<{ id: string; type: string; purpose?: string }>;
12
+ gitBranch?: string;
13
+ gitDirty?: boolean;
14
+ guardrails?: string[];
15
+ }
16
+
17
+ export function buildIdentityPreamble(ctx: IdentityContext): string {
18
+ const sections: string[] = [
19
+ `You are agent \`${ctx.agentId}\` (${ctx.agentType}) in workspace \`${ctx.workspace}\`.`,
20
+ ];
21
+
22
+ if (ctx.taskId !== undefined) {
23
+ const title = ctx.taskTitle === undefined ? '' : ` — ${ctx.taskTitle}`;
24
+ sections.push(`Your current task: #${ctx.taskId}${title}.`);
25
+ }
26
+
27
+ if (ctx.purpose !== undefined && ctx.purpose.trim() !== '') sections.push(`Your purpose: ${ctx.purpose}.`);
28
+ if (ctx.systemPrompt !== undefined && ctx.systemPrompt.trim() !== '') sections.push(ctx.systemPrompt);
29
+
30
+ if ((ctx.peers?.length ?? 0) > 0) {
31
+ sections.push(
32
+ [
33
+ 'Peer agents in this workspace:',
34
+ ...(ctx.peers ?? []).map(
35
+ (peer) => `- \`${peer.id}\` (${peer.type}) — ${peer.purpose ?? '(no purpose set)'}`,
36
+ ),
37
+ ].join('\n'),
38
+ );
39
+ }
40
+
41
+ sections.push(
42
+ [
43
+ 'Communication:',
44
+ '- Send a message with: spur message send --to <agent-id> "<message>"',
45
+ '- Reply to a message with: spur message reply <msg-id> "<response>"',
46
+ '- Read pending messages with: spur message inbox',
47
+ ].join('\n'),
48
+ );
49
+
50
+ if (ctx.gitBranch !== undefined) {
51
+ sections.push(
52
+ ['Git context:', `branch: ${ctx.gitBranch}`, `dirty: ${ctx.gitDirty === true ? 'true' : 'false'}`].join(
53
+ '\n',
54
+ ),
55
+ );
56
+ }
57
+
58
+ if ((ctx.guardrails?.length ?? 0) > 0) {
59
+ sections.push(['Guardrails:', ...(ctx.guardrails ?? []).map((guardrail) => `- ${guardrail}`)].join('\n'));
60
+ }
61
+
62
+ return `${sections.join('\n\n')}\n`;
63
+ }
64
+
65
+ export function getGitContext(
66
+ workspacePath: string,
67
+ executor: SyncProcessExecutor = new BunSyncProcessExecutor(),
68
+ ): string | null {
69
+ const git = Bun.which('git');
70
+ if (git === null) return null;
71
+
72
+ const branch = runGit(executor, git, ['-C', workspacePath, 'branch', '--show-current']);
73
+ if (branch === null || branch === '') return null;
74
+
75
+ const status = runGit(executor, git, ['-C', workspacePath, 'status', '--porcelain']);
76
+ const dirtyCount = status === null || status === '' ? 0 : status.split('\n').filter(Boolean).length;
77
+ return ['Git context:', `branch: ${branch}`, `dirty: ${dirtyCount === 0 ? 'false' : `${dirtyCount} files`}`].join(
78
+ '\n',
79
+ );
80
+ }
81
+
82
+ function runGit(executor: SyncProcessExecutor, command: string, args: string[]): string | null {
83
+ const result = executor.runSync({ command, args, rejectOnError: false, forceBuffered: true });
84
+ if (result.exitCode !== 0) return null;
85
+ return result.stdout.trim();
86
+ }
package/src/index.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  export * from './agent-detector';
2
+ export * from './agent-spec';
2
3
  export * from './agents/shims';
3
4
  export * from './ai-runner';
4
5
  export * from './doctor-runner';
6
+ export * from './identity';
7
+ export * from './message-service';
5
8
  export * from './slash-command';
9
+ export * from './team-agent-process';
10
+ export * from './team-orchestrator';
@@ -0,0 +1,33 @@
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
+ }
@@ -0,0 +1,133 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { BunPipeProcessSpawner, type PipeProcess, type PipeProcessSpawner } from '@gobing-ai/ts-runtime';
3
+ import type { AgentSpec } from './agent-spec';
4
+ import { buildIdentityPreamble } from './identity';
5
+
6
+ export interface AgentProcessOptions {
7
+ spec: AgentSpec;
8
+ command: string[];
9
+ env?: Record<string, string>;
10
+ cwd?: string;
11
+ processSpawner?: PipeProcessSpawner;
12
+ }
13
+
14
+ type ProcessStatus = 'running' | 'stopped' | 'errored';
15
+
16
+ export class TeamAgentProcess {
17
+ readonly agentId: string;
18
+ readonly identityPreamble: string;
19
+ private readonly command: string[];
20
+ private readonly env: Record<string, string> | undefined;
21
+ private readonly cwd: string | undefined;
22
+ private readonly processSpawner: PipeProcessSpawner;
23
+ private subprocess: PipeProcess | null = null;
24
+ private status: ProcessStatus = 'stopped';
25
+ private exitCode: number | null = null;
26
+ private readonly subscribers = new Set<(data: Buffer) => void>();
27
+
28
+ constructor(options: AgentProcessOptions) {
29
+ 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
+ this.command = options.command;
39
+ this.env = options.env;
40
+ this.cwd = options.cwd ?? options.spec.workspace;
41
+ this.processSpawner = options.processSpawner ?? new BunPipeProcessSpawner();
42
+ }
43
+
44
+ async start(): Promise<void> {
45
+ if (this.status === 'running') return;
46
+ const [command, ...args] = this.command;
47
+ if (command === undefined) throw new Error(`${this.agentId}: command must not be empty`);
48
+ this.subprocess = this.processSpawner.spawn({
49
+ command,
50
+ args,
51
+ ...(this.cwd !== undefined ? { cwd: this.cwd } : {}),
52
+ ...(this.env !== undefined ? { env: this.env } : {}),
53
+ });
54
+ this.status = 'running';
55
+ this.exitCode = null;
56
+ if (this.subprocess.stdout !== null) this.pipe(this.subprocess.stdout);
57
+ if (this.subprocess.stderr !== null) this.pipe(this.subprocess.stderr);
58
+ void this.subprocess.exited.then((code) => {
59
+ this.exitCode = code;
60
+ if (this.status === 'running') this.status = code === 0 ? 'stopped' : 'errored';
61
+ });
62
+ }
63
+
64
+ async stop(): Promise<void> {
65
+ const process = this.subprocess;
66
+ if (process === null) {
67
+ this.status = 'stopped';
68
+ return;
69
+ }
70
+ try {
71
+ process.endStdin();
72
+ } catch {
73
+ // Process may have already closed stdin.
74
+ }
75
+ process.kill('SIGTERM');
76
+ const timeout = Bun.sleep(5000).then(() => 'timeout' as const);
77
+ const result = await Promise.race([process.exited, timeout]);
78
+ if (result === 'timeout') {
79
+ process.kill('SIGKILL');
80
+ this.exitCode = await process.exited;
81
+ } else {
82
+ this.exitCode = result;
83
+ }
84
+ this.status = 'stopped';
85
+ this.subprocess = null;
86
+ }
87
+
88
+ async send(message: string): Promise<{ ok: boolean }> {
89
+ if (this.status !== 'running' || this.subprocess === null) return { ok: false };
90
+ try {
91
+ this.subprocess.writeStdin(`${message}\n`);
92
+ return { ok: true };
93
+ } catch {
94
+ this.status = 'errored';
95
+ return { ok: false };
96
+ }
97
+ }
98
+
99
+ subscribe(callback: (data: Buffer) => void): () => void {
100
+ this.subscribers.add(callback);
101
+ return () => {
102
+ this.subscribers.delete(callback);
103
+ };
104
+ }
105
+
106
+ getStatus(): ProcessStatus {
107
+ return this.status;
108
+ }
109
+
110
+ getPid(): number | null {
111
+ return this.subprocess?.pid ?? null;
112
+ }
113
+
114
+ getExitCode(): number | null {
115
+ return this.exitCode;
116
+ }
117
+
118
+ private async pipe(stream: ReadableStream<Uint8Array>): Promise<void> {
119
+ const reader = stream.getReader();
120
+ try {
121
+ while (true) {
122
+ const chunk = await reader.read();
123
+ if (chunk.done) break;
124
+ const buffer = Buffer.from(chunk.value);
125
+ for (const subscriber of this.subscribers) subscriber(buffer);
126
+ }
127
+ } catch {
128
+ if (this.status === 'running') this.status = 'errored';
129
+ } finally {
130
+ reader.releaseLock();
131
+ }
132
+ }
133
+ }
@@ -0,0 +1,148 @@
1
+ import type { AgentSpec } from './agent-spec';
2
+ import { loadAgentSpecs } from './agent-spec';
3
+ import { type AgentName, getAgentShim, isAgentName } from './agents/shims';
4
+ import { buildIdentityPreamble } from './identity';
5
+ import { MessageService } from './message-service';
6
+ import { TeamAgentProcess } from './team-agent-process';
7
+
8
+ type TeamEvent = 'agent.started' | 'agent.stopped' | 'message.sent';
9
+ type TeamListener = (payload: unknown) => void;
10
+ type AgentProcessFactory = (options: ConstructorParameters<typeof TeamAgentProcess>[0]) => TeamAgentProcess;
11
+
12
+ export interface TeamOrchestratorOptions {
13
+ processFactory?: AgentProcessFactory;
14
+ }
15
+
16
+ export class TeamOrchestrator {
17
+ private specs: AgentSpec[] = [];
18
+ private readonly running = new Map<string, TeamAgentProcess>();
19
+ private readonly listeners = new Map<TeamEvent, Set<TeamListener>>();
20
+ private readonly processFactory: AgentProcessFactory;
21
+
22
+ constructor(
23
+ private readonly configDir: string,
24
+ private readonly messageService: MessageService,
25
+ options: TeamOrchestratorOptions = {},
26
+ ) {
27
+ this.processFactory = options.processFactory ?? ((processOptions) => new TeamAgentProcess(processOptions));
28
+ }
29
+
30
+ loadSpecs(): AgentSpec[] {
31
+ this.specs = loadAgentSpecs(this.configDir);
32
+ return [...this.specs];
33
+ }
34
+
35
+ getSpec(id: string): AgentSpec | undefined {
36
+ if (this.specs.length === 0) this.loadSpecs();
37
+ return this.specs.find((spec) => spec.id === id);
38
+ }
39
+
40
+ async startAgent(id: string): Promise<TeamAgentProcess> {
41
+ const spec = this.requireSpec(id);
42
+ const agentType = this.requireAgentName(spec.type);
43
+ const peers = this.getPeerSpecs(spec.workspace, spec.id).map((peer) => ({
44
+ id: peer.id,
45
+ type: peer.type,
46
+ purpose: peer.purpose,
47
+ }));
48
+ const identityPreamble = buildIdentityPreamble({
49
+ agentId: spec.id,
50
+ agentType: spec.type,
51
+ workspace: spec.workspace,
52
+ purpose: spec.purpose,
53
+ systemPrompt: typeof spec.config.systemPrompt === 'string' ? spec.config.systemPrompt : undefined,
54
+ peers,
55
+ });
56
+ const command = getAgentShim(agentType).getPromptCommand({
57
+ input: identityPreamble,
58
+ purpose: spec.purpose,
59
+ systemPrompt: typeof spec.config.systemPrompt === 'string' ? spec.config.systemPrompt : undefined,
60
+ peers,
61
+ });
62
+ const process = this.processFactory({
63
+ spec,
64
+ command: [command.command, ...command.args],
65
+ cwd: spec.workspace,
66
+ });
67
+ await process.start();
68
+ this.running.set(id, process);
69
+ await this.injectPendingMessages(process);
70
+ this.emit('agent.started', { id });
71
+ return process;
72
+ }
73
+
74
+ async stopAgent(id: string): Promise<void> {
75
+ const process = this.running.get(id);
76
+ if (process === undefined) return;
77
+ await process.stop();
78
+ this.running.delete(id);
79
+ this.emit('agent.stopped', { id });
80
+ }
81
+
82
+ async restartAgent(id: string): Promise<TeamAgentProcess> {
83
+ await this.stopAgent(id);
84
+ return this.startAgent(id);
85
+ }
86
+
87
+ async sendMessage(fromId: string | null, toId: string, body: string, inReplyTo?: string): Promise<string> {
88
+ const msgId = await this.messageService.enqueue(fromId, toId, body, inReplyTo);
89
+ 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 });
92
+ return msgId;
93
+ }
94
+
95
+ getRunningAgents(): Map<string, TeamAgentProcess> {
96
+ return new Map(this.running);
97
+ }
98
+
99
+ getAgentStatus(id: string): 'running' | 'stopped' | 'errored' | 'unknown' {
100
+ const process = this.running.get(id);
101
+ if (process === undefined) return this.getSpec(id) === undefined ? 'unknown' : 'stopped';
102
+ return process.getStatus();
103
+ }
104
+
105
+ getPeerSpecs(workspace: string, excludeId?: string): AgentSpec[] {
106
+ if (this.specs.length === 0) this.loadSpecs();
107
+ return this.specs.filter((spec) => spec.workspace === workspace && spec.id !== excludeId);
108
+ }
109
+
110
+ async stopAll(): Promise<void> {
111
+ await Promise.all([...this.running.keys()].map((id) => this.stopAgent(id)));
112
+ }
113
+
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);
119
+ }
120
+
121
+ private requireSpec(id: string): AgentSpec {
122
+ const spec = this.getSpec(id);
123
+ if (spec === undefined) throw new Error(`Agent spec not found: ${id}`);
124
+ return spec;
125
+ }
126
+
127
+ private requireAgentName(type: string): AgentName {
128
+ if (!isAgentName(type)) throw new Error(`Unsupported agent type: ${type}`);
129
+ return type;
130
+ }
131
+
132
+ private injectPendingMessages(process: TeamAgentProcess): Promise<void> {
133
+ return this.flushInbox(process, 'startup stdin injection failed');
134
+ }
135
+
136
+ private async flushInbox(process: TeamAgentProcess, failLabel: string): Promise<void> {
137
+ const messages = await this.messageService.drain(process.agentId);
138
+ 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);
142
+ }
143
+ }
144
+
145
+ private emit(event: TeamEvent, payload: unknown): void {
146
+ for (const listener of this.listeners.get(event) ?? []) listener(payload);
147
+ }
148
+ }