feat(work): workTimeoutSec pro Agent — codex und kimi auf dasselbe Niveau

Der Work-Loop blockierte fuer ALLE Agenten 50s. Der Wert stammt von Kimi-Code,
dessen MCP-Client Requests nach gut einer Minute abbricht (-32001). Codex
vertraegt Minuten — musste aber mit demselben Minimum leben und kehrte dadurch
sechsmal so oft leer zurueck wie noetig. Jede Rueckkehr ist eine Gelegenheit,
den Turn zu beenden und aus dem Loop zu fallen; genau daran unterschied sich
codex' Zuverlaessigkeit von kimis.

Ein globaler Default zwingt alle auf die Grenze des schwaechsten Clients. Die
Grenze gehoert aber zum Agenten, nicht zum Hub — deshalb jetzt
`agents[].workTimeoutSec` im Roster, ausgeliefert ueber /agents/:agent/identity.
Aufloesung: explizites Argument > Roster > globaler Default.

Konfiguriert: codex 240s, kimi 50s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-07-29 20:57:52 +02:00
parent 6a4cd32312
commit e5bd28488d
4 changed files with 34 additions and 4 deletions

View File

@ -43,8 +43,8 @@ export const remoteClient = {
return request<HealthReport>(baseUrl, 'GET', '/health'); return request<HealthReport>(baseUrl, 'GET', '/health');
}, },
async getAgentIdentity(baseUrl: string, agent: string): Promise<{ canonical: string; names: string[] }> { async getAgentIdentity(baseUrl: string, agent: string): Promise<{ canonical: string; names: string[]; workTimeoutSec?: number }> {
return request<{ canonical: string; names: string[] }>( return request<{ canonical: string; names: string[]; workTimeoutSec?: number }>(
baseUrl, baseUrl,
'GET', 'GET',
`/agents/${encodeURIComponent(agent)}/identity`, `/agents/${encodeURIComponent(agent)}/identity`,

View File

@ -162,6 +162,17 @@ export const AgentConfigSchema = z.object({
dispatch: AgentDispatch.default('loop'), dispatch: AgentDispatch.default('loop'),
/** Previous/alternate names that resolve to this canonical roster key. */ /** Previous/alternate names that resolve to this canonical roster key. */
aliases: z.array(z.string().min(1)).default([]), aliases: z.array(z.string().min(1)).default([]),
/**
* Wie lange `agenthub_work` fuer DIESEN Agenten blockieren darf.
*
* Der Wert haengt am MCP-CLIENT, nicht am Hub: Kimi-Code bricht Requests nach
* gut einer Minute ab (-32001), Codex vertraegt mehrere Minuten. Ein globaler
* Default zwingt beide auf das Minimum des schwaechsten Clients der
* robustere Agent kehrt dann unnoetig oft leer zurueck, und JEDE Rueckkehr
* ist eine Gelegenheit, aus dem Loop zu fallen. Genau daran unterschied sich
* codex' Zuverlaessigkeit von kimis.
*/
workTimeoutSec: z.number().int().positive().max(600).optional(),
/** Display model, e.g. "Opus 4.8", "Sonnet 4.6", "Kimi K2", "GPT-5 Codex". */ /** Display model, e.g. "Opus 4.8", "Sonnet 4.6", "Kimi K2", "GPT-5 Codex". */
model: z.string().optional(), model: z.string().optional(),
/** Provider/company for the logo: "anthropic" | "openai" | "moonshot" | … */ /** Provider/company for the logo: "anthropic" | "openai" | "moonshot" | … */

View File

@ -301,7 +301,19 @@ export async function startMcpServer(cwd: string): Promise<void> {
? `No task in review for ${agent}, and no hub server to wait on.` ? `No task in review for ${agent}, and no hub server to wait on.`
: `No open task addressed to ${agent}, and no hub server to wait on.`; : `No open task addressed to ${agent}, and no hub server to wait on.`;
if (!remote) return asText(emptyMsg); if (!remote) return asText(emptyMsg);
const effectiveTimeout = timeoutSec ?? DEFAULT_WORK_TIMEOUT_SEC; // Timeout-Aufloesung: explizites Argument > Roster-Einstellung des Agenten
// > globaler Default. Der Roster-Wert existiert, weil die Grenze am
// MCP-CLIENT haengt: Kimi-Code bricht nach gut einer Minute ab, Codex
// vertraegt Minuten. Ein globaler Default zwingt alle auf das Minimum des
// schwaechsten Clients — der robustere Agent kehrt dann unnoetig oft leer
// zurueck, und jede Rueckkehr ist eine Gelegenheit, aus dem Loop zu fallen.
let rosterTimeout: number | undefined;
if (timeoutSec === undefined) {
try {
rosterTimeout = (await remoteClient.getAgentIdentity(serverUrl!, agent)).workTimeoutSec;
} catch { /* Roster nicht erreichbar — Default greift */ }
}
const effectiveTimeout = timeoutSec ?? rosterTimeout ?? DEFAULT_WORK_TIMEOUT_SEC;
let result; let result;
try { try {
result = await waitForTask(serverUrl!, finder, effectiveTimeout); result = await waitForTask(serverUrl!, finder, effectiveTimeout);

View File

@ -148,7 +148,14 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
app.get('/agents/:agent/identity', async (request, reply) => { app.get('/agents/:agent/identity', async (request, reply) => {
try { try {
const identity = agentIdentity(cwd, (request.params as { agent: string }).agent); const identity = agentIdentity(cwd, (request.params as { agent: string }).agent);
return { canonical: identity.canonical, names: [...identity.names] }; // workTimeoutSec gehoert fachlich zur Agenten-Identitaet: "wer bin ich und
// wie lange darf ich blockieren". Der MCP-Server holt es sich hier, statt
// dass jeder Client es raten muss.
let workTimeoutSec: number | undefined;
try {
workTimeoutSec = loadConfig(cwd).agents?.[identity.canonical]?.workTimeoutSec;
} catch { /* ohne Config bleibt der Default */ }
return { canonical: identity.canonical, names: [...identity.names], workTimeoutSec };
} catch (err) { } catch (err) {
return badRequest(reply, err instanceof Error ? err.message : String(err)); return badRequest(reply, err instanceof Error ? err.message : String(err));
} }