203 lines
7.2 KiB
TypeScript
203 lines
7.2 KiB
TypeScript
/**
|
|
* hwlab-code-agent 客户端核心逻辑
|
|
* 直接调 HWLAB v0.2 cloud-api,自动识别环境变量 HWLAB_API_KEY
|
|
* 遵循 cli-spec:配置从 config.json 单一来源
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
|
|
// ---- config ----
|
|
const SKILL_ROOT = path.resolve(import.meta.dir, "..", "..");
|
|
const configPath = path.join(SKILL_ROOT, "config.json");
|
|
|
|
interface Config {
|
|
apiBaseUrl: string;
|
|
defaultProjectId: string;
|
|
defaultPollTimeoutMs: number;
|
|
}
|
|
|
|
const INHERITED_PROVIDER_PROFILE_ENV = "HWLAB_CODE_AGENT_PROVIDER_PROFILE";
|
|
const PARENT_TRACE_ID_ENV = "HWLAB_CODE_AGENT_PARENT_TRACE_ID";
|
|
|
|
let _cfg: Config | null = null;
|
|
function config(): Config {
|
|
if (!_cfg) {
|
|
try {
|
|
_cfg = JSON.parse(readFileSync(configPath, "utf-8")) as Config;
|
|
} catch {
|
|
throw new Error(`hwlab-code-agent: failed to load config from ${configPath}`);
|
|
}
|
|
if (!_cfg.apiBaseUrl) throw new Error("hwlab-code-agent: config.apiBaseUrl is required");
|
|
}
|
|
return _cfg;
|
|
}
|
|
|
|
function textValue(value: string | undefined): string {
|
|
return String(value ?? "").trim();
|
|
}
|
|
|
|
function resolveProviderProfile(args: Record<string, string | undefined>) {
|
|
const explicit = textValue(args.profile || args.providerProfile);
|
|
if (explicit) return { profile: explicit, source: "explicit" };
|
|
const inherited = textValue(process.env[INHERITED_PROVIDER_PROFILE_ENV]);
|
|
if (inherited) return { profile: inherited, source: "env" };
|
|
fail("provider_profile_required", `spawn requires --profile or ${INHERITED_PROVIDER_PROFILE_ENV}`);
|
|
}
|
|
|
|
const REMOVED_WORKSPACE_FILE_ARGS = new Map([
|
|
["specPath", "--specPath"],
|
|
["spec-path", "--spec-path"]
|
|
]);
|
|
const REMOVED_WORKSPACE_FILE_ENV = ["HWPOD_SPEC_CONTENT", "HWPOD_SPEC"];
|
|
|
|
export function findRemovedWorkspaceFileInput(args: Record<string, string | undefined>, env: Record<string, string | undefined> = process.env): string | null {
|
|
for (const [key, label] of REMOVED_WORKSPACE_FILE_ARGS.entries()) {
|
|
if (textValue(args[key])) return label;
|
|
}
|
|
for (const name of REMOVED_WORKSPACE_FILE_ENV) {
|
|
if (textValue(env[name])) return name;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function createAgentChatRequestBody(input: {
|
|
message: string;
|
|
providerProfile: string;
|
|
sessionId: string;
|
|
conversationId: string;
|
|
projectId: string;
|
|
traceId: string;
|
|
}): Record<string, unknown> {
|
|
return {
|
|
message: input.message,
|
|
providerProfile: input.providerProfile,
|
|
sessionId: input.sessionId,
|
|
conversationId: input.conversationId,
|
|
projectId: input.projectId,
|
|
traceId: input.traceId,
|
|
shortConnection: true
|
|
};
|
|
}
|
|
|
|
function apiKey(): string {
|
|
const key = process.env.HWLAB_API_KEY;
|
|
if (!key) throw new Error("HWLAB_API_KEY not set in environment");
|
|
return key;
|
|
}
|
|
|
|
function authHeaders(): Record<string, string> {
|
|
return { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey()}` };
|
|
}
|
|
|
|
// ---- JSON out ----
|
|
function out(data: Record<string, unknown>, exitCode = 0): void {
|
|
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
if (exitCode !== 0) process.exit(exitCode);
|
|
}
|
|
|
|
function fail(code: string, msg: string, exitCode = 1): never {
|
|
process.stderr.write(JSON.stringify({ ok: false, error: { code, message: msg } }) + "\n");
|
|
process.exit(exitCode);
|
|
}
|
|
|
|
// ---- API ----
|
|
export interface SpawnResult {
|
|
ok: boolean;
|
|
action: string;
|
|
sessionId: string;
|
|
traceId: string;
|
|
conversationId: string;
|
|
accepted: boolean;
|
|
acceptedBody: unknown;
|
|
}
|
|
|
|
export async function spawn(args: Record<string, string | undefined>): Promise<void> {
|
|
const cfg = config();
|
|
const resolvedProfile = resolveProviderProfile(args);
|
|
const profile = resolvedProfile.profile;
|
|
const projectId = args.projectId || cfg.defaultProjectId;
|
|
const message = args.message || (args.messageFile ? readFileSync(args.messageFile, "utf-8") : null);
|
|
if (!message) fail("message_required", "--message or --message-file required");
|
|
const removedWorkspaceFileInput = findRemovedWorkspaceFileInput(args);
|
|
if (removedWorkspaceFileInput) {
|
|
fail("legacy_workspace_files_removed", `${removedWorkspaceFileInput} was removed; use AgentRun ResourceBundleRef kind=gitbundle with bundles[]`);
|
|
}
|
|
|
|
// session
|
|
let sessionId = args.sessionId || "";
|
|
let conversationId = args.conversationId || "";
|
|
if (!sessionId) {
|
|
const r = await fetch(`${cfg.apiBaseUrl}/v1/agent/sessions`, {
|
|
method: "POST", headers: authHeaders(),
|
|
body: JSON.stringify({ providerProfile: profile, projectId })
|
|
});
|
|
const b = await r.json() as Record<string, unknown>;
|
|
if (!r.ok) fail("session_create_failed", JSON.stringify(b));
|
|
const s = (b.session || b) as Record<string, unknown>;
|
|
sessionId = s.sessionId as string;
|
|
conversationId = conversationId || (s.conversationId as string) || "";
|
|
}
|
|
if (!conversationId) conversationId = `cnv_cli_${Date.now()}`;
|
|
|
|
// submit
|
|
const traceId = `trc_${profile}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
const r = await fetch(`${cfg.apiBaseUrl}/v1/agent/chat`, {
|
|
method: "POST",
|
|
headers: { ...authHeaders(), "prefer": "respond-async", "x-trace-id": traceId, "x-hwlab-short-connection": "1" },
|
|
body: JSON.stringify(createAgentChatRequestBody({ message, providerProfile: profile, sessionId, conversationId, projectId, traceId }))
|
|
});
|
|
const b = await r.json();
|
|
out({
|
|
ok: true, action: "spawn",
|
|
sessionId, traceId, conversationId,
|
|
providerProfile: profile,
|
|
resolvedProviderProfile: profile,
|
|
profileSource: resolvedProfile.source,
|
|
parentTraceId: textValue(process.env[PARENT_TRACE_ID_ENV]) || null,
|
|
accepted: r.ok,
|
|
acceptedBody: b,
|
|
pollCommand: `bun scripts/hwlab-code-agent-cli.ts poll ${traceId}`,
|
|
resultCommand: `bun scripts/hwlab-code-agent-cli.ts result ${traceId}`
|
|
});
|
|
}
|
|
|
|
export async function poll(traceId: string, args: Record<string, string | undefined>): Promise<void> {
|
|
const cfg = config();
|
|
const timeout = parseInt(args.timeout || String(cfg.defaultPollTimeoutMs), 10);
|
|
const deadline = Date.now() + timeout;
|
|
|
|
while (Date.now() < deadline) {
|
|
const r = await fetch(`${cfg.apiBaseUrl}/v1/agent/chat/result/${encodeURIComponent(traceId)}`, {
|
|
headers: authHeaders()
|
|
});
|
|
const b = await r.json() as Record<string, unknown>;
|
|
const s = (b.status || b.traceStatus) as string;
|
|
if (s === "completed" || s === "result") {
|
|
out({ ok: true, action: "poll", traceId, status: "completed", body: b });
|
|
return;
|
|
}
|
|
if (s === "failed" || s === "error") {
|
|
fail("poll_failed", JSON.stringify(b));
|
|
}
|
|
await new Promise(r => setTimeout(r, 2000));
|
|
}
|
|
fail("poll_timeout", `timeout after ${timeout}ms`);
|
|
}
|
|
|
|
export async function getResult(traceId: string): Promise<void> {
|
|
const cfg = config();
|
|
const r = await fetch(`${cfg.apiBaseUrl}/v1/agent/chat/result/${encodeURIComponent(traceId)}`, {
|
|
headers: authHeaders()
|
|
});
|
|
out({ ok: r.ok, action: "result", traceId, body: await r.json() });
|
|
}
|
|
|
|
export async function getTrace(traceId: string, args: Record<string, string | undefined>): Promise<void> {
|
|
const cfg = config();
|
|
const q = args.full ? "?full=true" : "";
|
|
const r = await fetch(`${cfg.apiBaseUrl}/v1/agent/chat/trace/${encodeURIComponent(traceId)}${q}`, {
|
|
headers: authHeaders()
|
|
});
|
|
out({ ok: r.ok, action: "trace", traceId, body: await r.json() });
|
|
}
|