@gobing-ai/ts-ai-runner 0.3.0 → 0.3.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/README.md +336 -32
- package/dist/agent-detector.d.ts +1 -0
- package/dist/agent-detector.d.ts.map +1 -1
- package/dist/agent-detector.js +13 -5
- package/dist/agent-spec.d.ts +6 -0
- package/dist/agent-spec.d.ts.map +1 -1
- package/dist/agent-spec.js +12 -8
- package/dist/ai-runner.d.ts +18 -2
- package/dist/ai-runner.d.ts.map +1 -1
- package/dist/ai-runner.js +52 -6
- package/dist/doctor-runner.d.ts +7 -0
- package/dist/doctor-runner.d.ts.map +1 -1
- package/dist/doctor-runner.js +69 -15
- package/dist/events.d.ts +38 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +0 -0
- package/dist/identity.d.ts +3 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/messages.d.ts +4 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +4 -0
- package/dist/team-agent-process.d.ts +12 -4
- package/dist/team-agent-process.d.ts.map +1 -1
- package/dist/team-agent-process.js +31 -19
- package/dist/team-orchestrator.d.ts +13 -8
- package/dist/team-orchestrator.d.ts.map +1 -1
- package/dist/team-orchestrator.js +32 -25
- package/package.json +4 -4
- package/src/agent-detector.ts +14 -5
- package/src/agent-spec.ts +20 -8
- package/src/ai-runner.ts +75 -13
- package/src/doctor-runner.ts +77 -16
- package/src/events.ts +25 -0
- package/src/identity.ts +3 -0
- package/src/index.ts +2 -1
- package/src/messages.ts +6 -0
- package/src/team-agent-process.ts +36 -21
- package/src/team-orchestrator.ts +36 -25
- package/dist/message-service.d.ts +0 -13
- package/dist/message-service.d.ts.map +0 -1
- package/dist/message-service.js +0 -27
- package/src/message-service.ts +0 -33
|
@@ -1,20 +1,26 @@
|
|
|
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
|
-
import {
|
|
4
|
+
import type { AgentEvents } from './events';
|
|
3
5
|
import { TeamAgentProcess } from './team-agent-process';
|
|
4
|
-
type TeamEvent = 'agent.started' | 'agent.stopped' | 'message.sent';
|
|
5
|
-
type TeamListener = (payload: unknown) => void;
|
|
6
6
|
type AgentProcessFactory = (options: ConstructorParameters<typeof TeamAgentProcess>[0]) => TeamAgentProcess;
|
|
7
|
+
/** Configuration options for `TeamOrchestrator`. */
|
|
7
8
|
export interface TeamOrchestratorOptions {
|
|
8
9
|
processFactory?: AgentProcessFactory;
|
|
10
|
+
events?: EventBus<AgentEvents>;
|
|
9
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Orchestrates a team of AI agents — loads specs, starts/stops agent processes, routes messages between them,
|
|
14
|
+
* and emits lifecycle events. Agents in the same workspace see each other as peers.
|
|
15
|
+
*/
|
|
10
16
|
export declare class TeamOrchestrator {
|
|
11
17
|
private readonly configDir;
|
|
12
|
-
private readonly
|
|
18
|
+
private readonly inbox;
|
|
13
19
|
private specs;
|
|
14
20
|
private readonly running;
|
|
15
|
-
private readonly listeners;
|
|
16
21
|
private readonly processFactory;
|
|
17
|
-
|
|
22
|
+
private readonly events;
|
|
23
|
+
constructor(configDir: string, inbox: InboxMessageDao, options?: TeamOrchestratorOptions);
|
|
18
24
|
loadSpecs(): AgentSpec[];
|
|
19
25
|
getSpec(id: string): AgentSpec | undefined;
|
|
20
26
|
startAgent(id: string): Promise<TeamAgentProcess>;
|
|
@@ -25,12 +31,11 @@ export declare class TeamOrchestrator {
|
|
|
25
31
|
getAgentStatus(id: string): 'running' | 'stopped' | 'errored' | 'unknown';
|
|
26
32
|
getPeerSpecs(workspace: string, excludeId?: string): AgentSpec[];
|
|
27
33
|
stopAll(): Promise<void>;
|
|
28
|
-
on(event:
|
|
34
|
+
on<K extends keyof AgentEvents>(event: K, listener: AgentEvents[K]): () => void;
|
|
29
35
|
private requireSpec;
|
|
30
36
|
private requireAgentName;
|
|
31
37
|
private injectPendingMessages;
|
|
32
38
|
private flushInbox;
|
|
33
|
-
private emit;
|
|
34
39
|
}
|
|
35
40
|
export {};
|
|
36
41
|
//# sourceMappingURL=team-orchestrator.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"team-orchestrator.d.ts","sourceRoot":"","sources":["../src/team-orchestrator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,
|
|
1
|
+
{"version":3,"file":"team-orchestrator.d.ts","sourceRoot":"","sources":["../src/team-orchestrator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAC9D,OAAO,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAC/C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAG9C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAG5C,OAAO,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAC;AAExD,KAAK,mBAAmB,GAAG,CAAC,OAAO,EAAE,qBAAqB,CAAC,OAAO,gBAAgB,CAAC,CAAC,CAAC,CAAC,KAAK,gBAAgB,CAAC;AAE5G,oDAAoD;AACpD,MAAM,WAAW,uBAAuB;IACpC,cAAc,CAAC,EAAE,mBAAmB,CAAC;IACrC,MAAM,CAAC,EAAE,QAAQ,CAAC,WAAW,CAAC,CAAC;CAClC;AAED;;;GAGG;AACH,qBAAa,gBAAgB;IAOrB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,QAAQ,CAAC,KAAK;IAP1B,OAAO,CAAC,KAAK,CAAmB;IAChC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAuC;IAC/D,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAsB;IACrD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAwB;gBAG1B,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,EACvC,OAAO,GAAE,uBAA4B;IAMzC,SAAS,IAAI,SAAS,EAAE;IAKxB,OAAO,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS;IAKpC,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAsCjD,SAAS,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQpC,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAKnD,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IAQzG,gBAAgB,IAAI,GAAG,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAIjD,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,SAAS;IAMzE,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,EAAE;IAK1D,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;IAI9B,EAAE,CAAC,CAAC,SAAS,MAAM,WAAW,EAAE,KAAK,EAAE,CAAC,EAAE,QAAQ,EAAE,WAAW,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI;IAK/E,OAAO,CAAC,WAAW;IAMnB,OAAO,CAAC,gBAAgB;YAKV,qBAAqB;YAIrB,UAAU;CAa3B"}
|
|
@@ -1,19 +1,25 @@
|
|
|
1
|
+
import { EventBus } from '@gobing-ai/ts-infra';
|
|
1
2
|
import { loadAgentSpecs } from './agent-spec.js';
|
|
2
3
|
import { getAgentShim, isAgentName } from './agents/shims.js';
|
|
3
4
|
import { buildIdentityPreamble } from './identity.js';
|
|
4
|
-
import {
|
|
5
|
+
import { formatMessage } from './messages.js';
|
|
5
6
|
import { TeamAgentProcess } from './team-agent-process.js';
|
|
7
|
+
/**
|
|
8
|
+
* Orchestrates a team of AI agents — loads specs, starts/stops agent processes, routes messages between them,
|
|
9
|
+
* and emits lifecycle events. Agents in the same workspace see each other as peers.
|
|
10
|
+
*/
|
|
6
11
|
export class TeamOrchestrator {
|
|
7
12
|
configDir;
|
|
8
|
-
|
|
13
|
+
inbox;
|
|
9
14
|
specs = [];
|
|
10
15
|
running = new Map();
|
|
11
|
-
listeners = new Map();
|
|
12
16
|
processFactory;
|
|
13
|
-
|
|
17
|
+
events;
|
|
18
|
+
constructor(configDir, inbox, options = {}) {
|
|
14
19
|
this.configDir = configDir;
|
|
15
|
-
this.
|
|
20
|
+
this.inbox = inbox;
|
|
16
21
|
this.processFactory = options.processFactory ?? ((processOptions) => new TeamAgentProcess(processOptions));
|
|
22
|
+
this.events = options.events ?? new EventBus();
|
|
17
23
|
}
|
|
18
24
|
loadSpecs() {
|
|
19
25
|
this.specs = loadAgentSpecs(this.configDir);
|
|
@@ -54,7 +60,11 @@ export class TeamOrchestrator {
|
|
|
54
60
|
await process.start();
|
|
55
61
|
this.running.set(id, process);
|
|
56
62
|
await this.injectPendingMessages(process);
|
|
57
|
-
this.emit('agent.started', {
|
|
63
|
+
void this.events.emit('agent.started', {
|
|
64
|
+
agentId: spec.id,
|
|
65
|
+
agentType: spec.type,
|
|
66
|
+
pid: process.getPid(),
|
|
67
|
+
});
|
|
58
68
|
return process;
|
|
59
69
|
}
|
|
60
70
|
async stopAgent(id) {
|
|
@@ -63,18 +73,17 @@ export class TeamOrchestrator {
|
|
|
63
73
|
return;
|
|
64
74
|
await process.stop();
|
|
65
75
|
this.running.delete(id);
|
|
66
|
-
this.emit('agent.stopped', { id });
|
|
76
|
+
void this.events.emit('agent.stopped', { agentId: id, exitCode: process.getExitCode() });
|
|
67
77
|
}
|
|
68
78
|
async restartAgent(id) {
|
|
69
79
|
await this.stopAgent(id);
|
|
70
80
|
return this.startAgent(id);
|
|
71
81
|
}
|
|
72
82
|
async sendMessage(fromId, toId, body, inReplyTo) {
|
|
73
|
-
const msgId = await this.
|
|
83
|
+
const msgId = await this.inbox.enqueue(fromId, toId, body, inReplyTo);
|
|
74
84
|
const process = this.running.get(toId);
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this.emit('message.sent', { id: msgId, fromId, toId });
|
|
85
|
+
const ok = process !== undefined ? await this.flushInbox(process, 'live stdin injection failed') : false;
|
|
86
|
+
void this.events.emit('agent.message.sent', { agentId: toId, ok });
|
|
78
87
|
return msgId;
|
|
79
88
|
}
|
|
80
89
|
getRunningAgents() {
|
|
@@ -95,10 +104,8 @@ export class TeamOrchestrator {
|
|
|
95
104
|
await Promise.all([...this.running.keys()].map((id) => this.stopAgent(id)));
|
|
96
105
|
}
|
|
97
106
|
on(event, listener) {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
this.listeners.set(event, listeners);
|
|
101
|
-
return () => listeners.delete(listener);
|
|
107
|
+
this.events.on(event, listener);
|
|
108
|
+
return () => this.events.off(event, listener);
|
|
102
109
|
}
|
|
103
110
|
requireSpec(id) {
|
|
104
111
|
const spec = this.getSpec(id);
|
|
@@ -111,21 +118,21 @@ export class TeamOrchestrator {
|
|
|
111
118
|
throw new Error(`Unsupported agent type: ${type}`);
|
|
112
119
|
return type;
|
|
113
120
|
}
|
|
114
|
-
injectPendingMessages(process) {
|
|
121
|
+
async injectPendingMessages(process) {
|
|
115
122
|
return this.flushInbox(process, 'startup stdin injection failed');
|
|
116
123
|
}
|
|
117
124
|
async flushInbox(process, failLabel) {
|
|
118
|
-
const messages = await this.
|
|
125
|
+
const messages = await this.inbox.drainPending(process.agentId);
|
|
126
|
+
let ok = true;
|
|
119
127
|
for (const message of messages) {
|
|
120
|
-
const result = await process.send(
|
|
128
|
+
const result = await process.send(formatMessage(message));
|
|
121
129
|
if (result.ok)
|
|
122
|
-
await this.
|
|
123
|
-
else
|
|
124
|
-
|
|
130
|
+
await this.inbox.markDelivered(message.id);
|
|
131
|
+
else {
|
|
132
|
+
ok = false;
|
|
133
|
+
await this.inbox.markFailed(message.id, failLabel);
|
|
134
|
+
}
|
|
125
135
|
}
|
|
126
|
-
|
|
127
|
-
emit(event, payload) {
|
|
128
|
-
for (const listener of this.listeners.get(event) ?? [])
|
|
129
|
-
listener(payload);
|
|
136
|
+
return ok;
|
|
130
137
|
}
|
|
131
138
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gobing-ai/ts-ai-runner",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "@gobing-ai/ts-ai-runner — Coding-agent shims, detection, doctor checks, and prompt execution.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
"release": "echo 'Manual publish is disabled. Releases go through GitHub Actions via Trusted Publishing — push a tag: git tag @gobing-ai/ts-ai-runner-v<version> && git push --tags' && exit 1"
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
|
-
"@gobing-ai/ts-db": "^0.3.
|
|
51
|
-
"@gobing-ai/ts-
|
|
52
|
-
"@gobing-ai/ts-
|
|
50
|
+
"@gobing-ai/ts-db": "^0.3.2",
|
|
51
|
+
"@gobing-ai/ts-infra": "^0.3.2",
|
|
52
|
+
"@gobing-ai/ts-runtime": "^0.3.2"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@types/bun": "1.3.14"
|
package/src/agent-detector.ts
CHANGED
|
@@ -58,24 +58,33 @@ export class AgentDetector {
|
|
|
58
58
|
if (lower.includes('command not found') || lower.includes('enoent') || lower.includes('not recognized')) {
|
|
59
59
|
return unavailable(agent, `${command}: command not found`);
|
|
60
60
|
}
|
|
61
|
-
if (result.signal !== undefined
|
|
62
|
-
return unavailable(agent, result.signal
|
|
61
|
+
if (result.signal !== undefined) {
|
|
62
|
+
return unavailable(agent, `Terminated by signal: ${result.signal}`);
|
|
63
|
+
}
|
|
64
|
+
if (result.exitCode === null) {
|
|
65
|
+
return unavailable(agent, 'Process did not produce an exit code');
|
|
63
66
|
}
|
|
64
67
|
if (result.exitCode !== 0) {
|
|
65
|
-
return unavailable(agent, `Non-zero exit code ${result.exitCode}: ${result.stderr.slice(0, 200)}`);
|
|
68
|
+
return unavailable(agent, `Non-zero exit code: ${result.exitCode}. stderr: ${result.stderr.slice(0, 200)}`);
|
|
66
69
|
}
|
|
67
70
|
const match = VERSION_PATTERN.exec(output);
|
|
68
71
|
if (match?.groups?.version === undefined) {
|
|
69
72
|
return unavailable(agent, 'Could not parse version output');
|
|
70
73
|
}
|
|
74
|
+
const version = output.split('\n')[0]?.trim() || match.groups.version;
|
|
71
75
|
return {
|
|
72
76
|
name: agent,
|
|
73
77
|
installed: true,
|
|
74
|
-
version
|
|
75
|
-
channels:
|
|
78
|
+
version,
|
|
79
|
+
channels: this.detectChannels(agent, output),
|
|
76
80
|
error: null,
|
|
77
81
|
};
|
|
78
82
|
}
|
|
83
|
+
|
|
84
|
+
private detectChannels(_agent: AgentName, _output: string): string[] {
|
|
85
|
+
// Phase 2 hook: parse per-agent channel/model output here when shims expose it.
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
/** Build an "unavailable" detection result for an agent with the given error. */
|
package/src/agent-spec.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
basenamePath,
|
|
3
|
+
joinPath,
|
|
4
|
+
NodeSyncFileSystem,
|
|
5
|
+
parseYamlObject,
|
|
6
|
+
type SyncFileSystem,
|
|
7
|
+
stringifyYamlObject,
|
|
8
|
+
} from '@gobing-ai/ts-runtime';
|
|
3
9
|
|
|
10
|
+
/** Specification for an AI agent defined in the configuration directory. */
|
|
4
11
|
export interface AgentSpec {
|
|
5
12
|
id: string;
|
|
6
13
|
name: string;
|
|
@@ -12,6 +19,7 @@ export interface AgentSpec {
|
|
|
12
19
|
autoStart?: boolean;
|
|
13
20
|
}
|
|
14
21
|
|
|
22
|
+
/** Validation error thrown when agent spec data is malformed or invalid. */
|
|
15
23
|
export class ValueError extends Error {
|
|
16
24
|
constructor(message: string) {
|
|
17
25
|
super(message);
|
|
@@ -19,6 +27,7 @@ export class ValueError extends Error {
|
|
|
19
27
|
}
|
|
20
28
|
}
|
|
21
29
|
|
|
30
|
+
/** Validate that `id` matches the agent ID format: 2-64 chars, lowercase alphanumeric with `_` or `-`. Returns the id on success, throws `ValueError` otherwise. */
|
|
22
31
|
export function validateAgentId(id: string): string {
|
|
23
32
|
if (!/^[a-z][a-z0-9_-]{1,63}$/.test(id)) {
|
|
24
33
|
throw new ValueError(`Invalid agent id "${id}": expected 2-64 chars, lowercase alphanumeric, "_" or "-"`);
|
|
@@ -26,11 +35,12 @@ export function validateAgentId(id: string): string {
|
|
|
26
35
|
return id;
|
|
27
36
|
}
|
|
28
37
|
|
|
38
|
+
/** Load and validate all YAML agent spec files from `configDir`. Throws `ValueError` on parse failures or duplicate IDs. */
|
|
29
39
|
export function loadAgentSpecs(configDir: string, fs: SyncFileSystem = new NodeSyncFileSystem()): AgentSpec[] {
|
|
30
40
|
const entries = safeReadDir(configDir, fs)
|
|
31
41
|
.filter((entry) => entry.endsWith('.yaml') || entry.endsWith('.yml'))
|
|
32
42
|
.sort();
|
|
33
|
-
const specs = entries.map((entry) => parseAgentSpec(fs.readFile(
|
|
43
|
+
const specs = entries.map((entry) => parseAgentSpec(fs.readFile(joinPath(configDir, entry)), entry));
|
|
34
44
|
const seen = new Set<string>();
|
|
35
45
|
for (const spec of specs) {
|
|
36
46
|
validateAgentId(spec.id);
|
|
@@ -40,6 +50,7 @@ export function loadAgentSpecs(configDir: string, fs: SyncFileSystem = new NodeS
|
|
|
40
50
|
return specs;
|
|
41
51
|
}
|
|
42
52
|
|
|
53
|
+
/** Serialize `spec` to YAML and write it to `configDir/<id>.yaml`. Creates the directory if missing. */
|
|
43
54
|
export async function saveAgentSpec(
|
|
44
55
|
spec: AgentSpec,
|
|
45
56
|
configDir: string,
|
|
@@ -47,16 +58,17 @@ export async function saveAgentSpec(
|
|
|
47
58
|
): Promise<void> {
|
|
48
59
|
validateAgentId(spec.id);
|
|
49
60
|
fs.mkdir(configDir);
|
|
50
|
-
fs.writeFile(
|
|
61
|
+
fs.writeFile(joinPath(configDir, `${spec.id}.yaml`), serializeAgentSpec(spec));
|
|
51
62
|
}
|
|
52
63
|
|
|
64
|
+
/** Remove the YAML file for agent `id` from `configDir`. Does nothing if the file doesn't exist. */
|
|
53
65
|
export async function deleteAgentSpec(
|
|
54
66
|
id: string,
|
|
55
67
|
configDir: string,
|
|
56
68
|
fs: SyncFileSystem = new NodeSyncFileSystem(),
|
|
57
69
|
): Promise<void> {
|
|
58
70
|
validateAgentId(id);
|
|
59
|
-
fs.unlink(
|
|
71
|
+
fs.unlink(joinPath(configDir, `${id}.yaml`));
|
|
60
72
|
}
|
|
61
73
|
|
|
62
74
|
function safeReadDir(configDir: string, fs: SyncFileSystem = new NodeSyncFileSystem()): string[] {
|
|
@@ -102,7 +114,7 @@ function serializeAgentSpec(spec: AgentSpec): string {
|
|
|
102
114
|
function requireString(source: Record<string, unknown>, key: keyof AgentSpec, fileName: string): string {
|
|
103
115
|
const value = source[key];
|
|
104
116
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
105
|
-
throw new ValueError(`${
|
|
117
|
+
throw new ValueError(`${basenamePath(fileName)}: "${key}" must be a non-empty string`);
|
|
106
118
|
}
|
|
107
119
|
return value;
|
|
108
120
|
}
|
|
@@ -110,7 +122,7 @@ function requireString(source: Record<string, unknown>, key: keyof AgentSpec, fi
|
|
|
110
122
|
function requireStringArray(source: Record<string, unknown>, key: keyof AgentSpec, fileName: string): string[] {
|
|
111
123
|
const value = source[key];
|
|
112
124
|
if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) {
|
|
113
|
-
throw new ValueError(`${
|
|
125
|
+
throw new ValueError(`${basenamePath(fileName)}: "${key}" must be a string array`);
|
|
114
126
|
}
|
|
115
127
|
return value;
|
|
116
128
|
}
|
|
@@ -122,7 +134,7 @@ function requireRecord(
|
|
|
122
134
|
): Record<string, unknown> {
|
|
123
135
|
const value = source[key];
|
|
124
136
|
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
125
|
-
throw new ValueError(`${
|
|
137
|
+
throw new ValueError(`${basenamePath(fileName)}: "${key}" must be an object`);
|
|
126
138
|
}
|
|
127
139
|
return value as Record<string, unknown>;
|
|
128
140
|
}
|
package/src/ai-runner.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { type EventBus, getLogger, type Logger, traceAsync } from '@gobing-ai/ts-infra';
|
|
2
|
+
import {
|
|
3
|
+
getProcessCwd,
|
|
4
|
+
NodeProcessExecutor,
|
|
5
|
+
type ProcessExecutor,
|
|
6
|
+
type ProcessResult,
|
|
7
|
+
type TracerPort,
|
|
8
|
+
} from '@gobing-ai/ts-runtime';
|
|
9
|
+
import { type AgentName, getAgentShim, type PromptOptions, type ShimCommand } from './agents/shims';
|
|
10
|
+
import type { AgentEvents, AiRunnerProcessEvents } from './events';
|
|
3
11
|
import { buildIdentityPreamble } from './identity';
|
|
12
|
+
import { translateSlashCommand } from './slash-command';
|
|
4
13
|
|
|
5
14
|
/** Result returned by every AI runner dispatch method. */
|
|
6
15
|
export interface AgentRunResult {
|
|
@@ -32,6 +41,14 @@ export interface AiRunnerOptions {
|
|
|
32
41
|
defaultCwd?: string;
|
|
33
42
|
/** Default timeout in milliseconds. */
|
|
34
43
|
defaultTimeout?: number;
|
|
44
|
+
/** Logger for invocation diagnostics. Defaults to `getLogger('ai-runner')`. */
|
|
45
|
+
logger?: Logger;
|
|
46
|
+
/** Event bus receiving process-level observability from the default executor. */
|
|
47
|
+
processEvents?: EventBus<AiRunnerProcessEvents>;
|
|
48
|
+
/** Event bus receiving agent-level invocation observability. */
|
|
49
|
+
events?: EventBus<AgentEvents>;
|
|
50
|
+
/** Tracer adapter for the default executor. Defaults to `ts-infra` traceAsync. */
|
|
51
|
+
tracer?: TracerPort;
|
|
35
52
|
}
|
|
36
53
|
|
|
37
54
|
/** Dispatches coding-agent CLI commands through pure command shims. */
|
|
@@ -39,11 +56,30 @@ export class AiRunner {
|
|
|
39
56
|
private readonly processExecutor: ProcessExecutor;
|
|
40
57
|
private readonly defaultCwd: string | undefined;
|
|
41
58
|
private readonly defaultTimeout: number | undefined;
|
|
59
|
+
private readonly logger: Logger;
|
|
60
|
+
private readonly events: EventBus<AgentEvents> | undefined;
|
|
42
61
|
|
|
43
62
|
constructor(options: AiRunnerOptions = {}) {
|
|
44
|
-
this.processExecutor =
|
|
63
|
+
this.processExecutor =
|
|
64
|
+
options.processExecutor ??
|
|
65
|
+
new NodeProcessExecutor({
|
|
66
|
+
...(options.processEvents !== undefined
|
|
67
|
+
? {
|
|
68
|
+
events: {
|
|
69
|
+
emit: (event, detail) => {
|
|
70
|
+
void options.processEvents?.emit(event, detail);
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
: {}),
|
|
75
|
+
tracer: options.tracer ?? {
|
|
76
|
+
traceAsync: async (name, fn) => await traceAsync(name, (span) => fn(span)),
|
|
77
|
+
},
|
|
78
|
+
});
|
|
45
79
|
this.defaultCwd = options.defaultCwd;
|
|
46
80
|
this.defaultTimeout = options.defaultTimeout;
|
|
81
|
+
this.logger = options.logger ?? getLogger('ai-runner');
|
|
82
|
+
this.events = options.events;
|
|
47
83
|
}
|
|
48
84
|
|
|
49
85
|
/** Run an agent help command. */
|
|
@@ -62,14 +98,22 @@ export class AiRunner {
|
|
|
62
98
|
promptOptions: PromptOptions,
|
|
63
99
|
options: AgentRunOptions = {},
|
|
64
100
|
): Promise<AgentRunResult> {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
101
|
+
return this.invoke(agent, 'prompt', this.buildPromptCommand(agent, promptOptions, options), options, false);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Translate a Claude-style slash command and run it as a prompt command. */
|
|
105
|
+
runSlashCommand(
|
|
106
|
+
agent: AgentName,
|
|
107
|
+
input: string,
|
|
108
|
+
promptOptions: PromptOptions,
|
|
109
|
+
options: AgentRunOptions = {},
|
|
110
|
+
): Promise<AgentRunResult> {
|
|
111
|
+
return this.runPromptCommand(agent, { ...promptOptions, input: translateSlashCommand(agent, input) }, options);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Build an agent prompt command without executing it. */
|
|
115
|
+
buildPromptCommand(agent: AgentName, promptOptions: PromptOptions, options: AgentRunOptions = {}): ShimCommand {
|
|
116
|
+
return getAgentShim(agent).getPromptCommand(this.withIdentityPreamble(agent, promptOptions, options));
|
|
73
117
|
}
|
|
74
118
|
|
|
75
119
|
/** Run an agent authentication command, or return null when unsupported. */
|
|
@@ -85,15 +129,33 @@ export class AiRunner {
|
|
|
85
129
|
options: AgentRunOptions,
|
|
86
130
|
forceBuffered: boolean,
|
|
87
131
|
): Promise<AgentRunResult> {
|
|
132
|
+
const label = `ai-runner.${agent}.${operation}`;
|
|
133
|
+
this.logger.debug('invoke', { label, command: command.command, args: command.args.join(' ') });
|
|
134
|
+
void this.events?.emit('agent.invoke.start', { agent, operation, label });
|
|
88
135
|
const result: ProcessResult = await this.processExecutor.run({
|
|
89
136
|
command: command.command,
|
|
90
137
|
args: command.args,
|
|
91
|
-
label
|
|
138
|
+
label,
|
|
92
139
|
rejectOnError: false,
|
|
93
140
|
forceBuffered,
|
|
94
141
|
cwd: options.cwd ?? this.defaultCwd,
|
|
95
142
|
timeout: options.timeout ?? this.defaultTimeout,
|
|
96
143
|
});
|
|
144
|
+
if (result.exitCode !== 0) {
|
|
145
|
+
this.logger.error('invoke exited non-zero', {
|
|
146
|
+
label,
|
|
147
|
+
exitCode: result.exitCode,
|
|
148
|
+
signal: result.signal,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
void this.events?.emit('agent.invoke.exit', {
|
|
152
|
+
agent,
|
|
153
|
+
operation,
|
|
154
|
+
label,
|
|
155
|
+
exitCode: result.exitCode,
|
|
156
|
+
...(result.signal !== undefined ? { signal: result.signal } : {}),
|
|
157
|
+
durationMs: result.durationMs,
|
|
158
|
+
});
|
|
97
159
|
return {
|
|
98
160
|
exitCode: result.exitCode,
|
|
99
161
|
stdout: result.stdout,
|
|
@@ -109,7 +171,7 @@ export class AiRunner {
|
|
|
109
171
|
options: AgentRunOptions,
|
|
110
172
|
): PromptOptions {
|
|
111
173
|
if (!hasIdentityOptions(promptOptions)) return promptOptions;
|
|
112
|
-
const workspace = options.cwd ?? this.defaultCwd ??
|
|
174
|
+
const workspace = options.cwd ?? this.defaultCwd ?? getProcessCwd();
|
|
113
175
|
const preamble = buildIdentityPreamble({
|
|
114
176
|
agentId: agent,
|
|
115
177
|
agentType: agent,
|
package/src/doctor-runner.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { getProcessEnv, NodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
1
|
+
import { getLogger, type Logger } from '@gobing-ai/ts-infra';
|
|
2
|
+
import { getProcessEnv, joinPath, NodeFileSystem } from '@gobing-ai/ts-runtime';
|
|
4
3
|
import { AgentDetector, type DetectedAgent } from './agent-detector';
|
|
5
|
-
import { type AgentName, isAgentName, TIER2_AGENTS } from './agents/shims';
|
|
6
|
-
import { AiRunner } from './ai-runner';
|
|
4
|
+
import { type AgentName, DISPLAY_ORDER, isAgentName, TIER2_AGENTS } from './agents/shims';
|
|
5
|
+
import { type AgentRunResult, AiRunner } from './ai-runner';
|
|
7
6
|
|
|
8
7
|
/** Health-check result for one coding agent. */
|
|
9
8
|
export interface DoctorResult {
|
|
@@ -35,9 +34,34 @@ export interface DoctorRunnerOptions {
|
|
|
35
34
|
timeout?: number;
|
|
36
35
|
/** Environment map for file/env auth checks. */
|
|
37
36
|
env?: Record<string, string | undefined>;
|
|
37
|
+
/** Logger for health-check diagnostics. Defaults to `getLogger('doctor')`. */
|
|
38
|
+
logger?: Logger;
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
42
|
+
const AUTH_PATTERNS: Partial<Record<AgentName, { positive: RegExp; negative: RegExp }>> = {
|
|
43
|
+
claude: {
|
|
44
|
+
positive: /authenticated|logged[\s_-]*in|"loggedIn"\s*:\s*true/i,
|
|
45
|
+
negative:
|
|
46
|
+
/not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated|"loggedIn"\s*:\s*false/i,
|
|
47
|
+
},
|
|
48
|
+
codex: {
|
|
49
|
+
positive: /logged[\s_-]*in|authenticated/i,
|
|
50
|
+
negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|logged[\s_-]*out|unauthenticated/i,
|
|
51
|
+
},
|
|
52
|
+
opencode: {
|
|
53
|
+
positive: /configured|available/i,
|
|
54
|
+
negative: /not[\s_-]*configured|no[\s_-]+providers?[\s_-]+available|unavailable/i,
|
|
55
|
+
},
|
|
56
|
+
openclaw: {
|
|
57
|
+
positive: /(^|[^a-z])ok([^a-z]|$)|healthy/i,
|
|
58
|
+
negative: /not[\s_-]*healthy|unhealthy|not[\s_-]*ok/i,
|
|
59
|
+
},
|
|
60
|
+
pi: {
|
|
61
|
+
positive: /\S/,
|
|
62
|
+
negative: /not[\s_-]*authenticated|not[\s_-]*logged[\s_-]*in|unauthenticated|no[\s_-]+providers?/i,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
41
65
|
|
|
42
66
|
/** True when a value is a defined, non-blank string. */
|
|
43
67
|
function isNonEmpty(value: string | undefined): boolean {
|
|
@@ -51,18 +75,33 @@ export class DoctorRunner {
|
|
|
51
75
|
private readonly timeout: number;
|
|
52
76
|
private readonly env: Record<string, string | undefined>;
|
|
53
77
|
private readonly fs = new NodeFileSystem();
|
|
78
|
+
private readonly logger: Logger;
|
|
54
79
|
|
|
55
80
|
constructor(options: DoctorRunnerOptions = {}) {
|
|
56
81
|
this.runner = options.runner ?? new AiRunner();
|
|
57
82
|
this.detector = options.agentDetector ?? new AgentDetector({ runner: this.runner });
|
|
58
83
|
this.timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
59
84
|
this.env = options.env ?? getProcessEnv();
|
|
85
|
+
this.logger = options.logger ?? getLogger('doctor');
|
|
60
86
|
}
|
|
61
87
|
|
|
62
88
|
/** Run a health check on all supported agents. */
|
|
63
89
|
async runAll(): Promise<DoctorResult[]> {
|
|
64
90
|
const detected = await this.detector.detectAll();
|
|
65
|
-
|
|
91
|
+
const byName = new Map(detected.map((agent) => [agent.name, agent]));
|
|
92
|
+
return await Promise.all(
|
|
93
|
+
DISPLAY_ORDER.map((agent) =>
|
|
94
|
+
this.buildResult(
|
|
95
|
+
byName.get(agent) ?? {
|
|
96
|
+
name: agent,
|
|
97
|
+
installed: false,
|
|
98
|
+
version: null,
|
|
99
|
+
channels: [],
|
|
100
|
+
error: `Unknown agent: ${agent}`,
|
|
101
|
+
},
|
|
102
|
+
),
|
|
103
|
+
),
|
|
104
|
+
);
|
|
66
105
|
}
|
|
67
106
|
|
|
68
107
|
/** Run a health check on one agent. */
|
|
@@ -72,6 +111,7 @@ export class DoctorRunner {
|
|
|
72
111
|
|
|
73
112
|
private async buildResult(detected: DetectedAgent): Promise<DoctorResult> {
|
|
74
113
|
const tier = TIER2_AGENTS.has(detected.name as AgentName) ? 2 : 1;
|
|
114
|
+
this.logger.debug('checking agent', { agent: detected.name, installed: detected.installed, tier });
|
|
75
115
|
const authenticated =
|
|
76
116
|
detected.installed && isAgentName(detected.name) ? await this.checkAuth(detected.name) : false;
|
|
77
117
|
return {
|
|
@@ -87,24 +127,45 @@ export class DoctorRunner {
|
|
|
87
127
|
}
|
|
88
128
|
|
|
89
129
|
private async checkAuth(agent: AgentName): Promise<boolean> {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (agent === 'gemini') return this.hasNonEmptyFile(join(homedir(), '.gemini', 'settings.json'));
|
|
94
|
-
if (agent === 'codex' && (await this.hasNonEmptyFile(join(homedir(), '.codex', 'auth.json')))) return true;
|
|
130
|
+
const home = this.env.HOME || this.env.USERPROFILE || '';
|
|
131
|
+
if (agent === 'gemini') return this.geminiSettingsContainCredentials(home);
|
|
132
|
+
if (agent === 'codex') return this.checkCodexAuth(home);
|
|
95
133
|
// pi reads provider keys from the environment; require a non-empty value
|
|
96
134
|
// rather than mere presence (an empty export is not a usable credential).
|
|
97
135
|
if (agent === 'pi' && (isNonEmpty(this.env.GOOGLE_API_KEY) || isNonEmpty(this.env.ANTHROPIC_API_KEY)))
|
|
98
136
|
return true;
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
137
|
+
return (await this.probeAuthOutput(agent)) === true;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async checkCodexAuth(home: string): Promise<boolean> {
|
|
141
|
+
const probeStatus = await this.probeAuthOutput('codex');
|
|
142
|
+
if (probeStatus !== null) return probeStatus;
|
|
102
143
|
return (
|
|
103
|
-
|
|
104
|
-
|
|
144
|
+
(await this.hasNonEmptyFile(joinPath(home, '.codex', 'auth.json'))) ||
|
|
145
|
+
(await this.hasNonEmptyFile(joinPath(home, '.codex', 'auth')))
|
|
105
146
|
);
|
|
106
147
|
}
|
|
107
148
|
|
|
149
|
+
private async geminiSettingsContainCredentials(home: string): Promise<boolean> {
|
|
150
|
+
try {
|
|
151
|
+
return /auth|token|key/i.test(await this.fs.readFile(joinPath(home, '.gemini', 'settings.json')));
|
|
152
|
+
} catch {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async probeAuthOutput(agent: AgentName): Promise<boolean | null> {
|
|
158
|
+
const command = this.runner.runAuthCommand(agent, { timeout: this.timeout });
|
|
159
|
+
const patterns = AUTH_PATTERNS[agent];
|
|
160
|
+
if (command === null || patterns === undefined) return null;
|
|
161
|
+
const result: AgentRunResult = await command;
|
|
162
|
+
if (result.exitCode !== 0) return false;
|
|
163
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
164
|
+
if (patterns.negative.test(output)) return false;
|
|
165
|
+
if (patterns.positive.test(output)) return true;
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
|
|
108
169
|
/** True when the path exists and has a non-zero size. */
|
|
109
170
|
private async hasNonEmptyFile(path: string): Promise<boolean> {
|
|
110
171
|
const stat = await this.fs.stat(path);
|
package/src/events.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ProcessEvents } from '@gobing-ai/ts-runtime';
|
|
2
|
+
|
|
3
|
+
/** Typed event map for agent-runner observability. All events prefixed `agent.`. */
|
|
4
|
+
export type AgentEvents = {
|
|
5
|
+
/** Emitted immediately before an agent CLI invocation starts. */
|
|
6
|
+
'agent.invoke.start': (data: { agent: string; operation: string; label: string }) => void;
|
|
7
|
+
/** Emitted after an agent CLI invocation exits. */
|
|
8
|
+
'agent.invoke.exit': (data: {
|
|
9
|
+
agent: string;
|
|
10
|
+
operation: string;
|
|
11
|
+
label: string;
|
|
12
|
+
exitCode: number | null;
|
|
13
|
+
signal?: string;
|
|
14
|
+
durationMs: number;
|
|
15
|
+
}) => void;
|
|
16
|
+
/** Emitted when a long-running team agent process starts. */
|
|
17
|
+
'agent.started': (data: { agentId: string; agentType: string; pid: number | null }) => void;
|
|
18
|
+
/** Emitted when a long-running team agent process stops. */
|
|
19
|
+
'agent.stopped': (data: { agentId: string; exitCode: number | null }) => void;
|
|
20
|
+
/** Emitted when a message is sent to a team agent process. */
|
|
21
|
+
'agent.message.sent': (data: { agentId: string; ok: boolean }) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** Event map for process-level observability emitted by AiRunner-owned executors. */
|
|
25
|
+
export type AiRunnerProcessEvents = ProcessEvents;
|