Skip to content

Commit ffd3641

Browse files
zhmzmclaude
andcommitted
feat: enhance agentclick client with rewrite loop, ensureServer, and structured results
Adds sub-agent rewrite loop support (onRewrite callback + maxRounds), server identity verification (ensureServer), structured result mode, and late-binding base URL resolution — all backward compatible. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7a4bf0b commit ffd3641

File tree

1 file changed

+161
-22
lines changed

1 file changed

+161
-22
lines changed

lib/agentclick.js

Lines changed: 161 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,179 @@
1-
const AGENTCLICK_PORT = process.env.AGENTCLICK_PORT || process.env.PORT || '38173';
2-
const AGENTCLICK_URL = process.env.AGENTCLICK_URL || `http://localhost:${AGENTCLICK_PORT}`;
1+
/**
2+
* AgentClick client library.
3+
* Agents use this to create review sessions and block until a human responds.
4+
*/
5+
6+
/** Re-reads env vars at call time for late-binding in Docker/orchestrator contexts. */
7+
function getBaseUrl() {
8+
if (process.env.AGENTCLICK_URL) return process.env.AGENTCLICK_URL;
9+
const port = process.env.AGENTCLICK_PORT || process.env.PORT || '38173';
10+
return `http://localhost:${port}`;
11+
}
12+
13+
/**
14+
* Verify that an AgentClick server is reachable and returns the expected identity.
15+
* @param {Object} [options]
16+
* @param {number} [options.timeout=3000] - Timeout in ms
17+
* @returns {Promise<Object>} Identity object from the server
18+
*/
19+
async function ensureServer(options = {}) {
20+
const { timeout = 3000 } = options;
21+
const controller = new AbortController();
22+
const timer = setTimeout(() => controller.abort(), timeout);
23+
24+
try {
25+
const res = await fetch(`${getBaseUrl()}/api/identity`, {
26+
signal: controller.signal,
27+
});
28+
if (!res.ok) {
29+
throw new Error(`AgentClick identity check failed: ${res.status} ${res.statusText}`);
30+
}
31+
const identity = await res.json();
32+
if (identity.service !== 'agentclick') {
33+
throw new Error(`Unexpected service: ${identity.service}`);
34+
}
35+
return identity;
36+
} catch (err) {
37+
if (err.name === 'AbortError') {
38+
throw new Error(`AgentClick server not reachable at ${getBaseUrl()} (timeout ${timeout}ms)`);
39+
}
40+
throw err;
41+
} finally {
42+
clearTimeout(timer);
43+
}
44+
}
345

446
/**
547
* Send an action for human review and wait for approval.
648
* @param {Object} options
749
* @param {string} options.type - 'code_review' | 'email_review' | 'action_approval' | 'plan_review' | 'trajectory_review'
8-
* @param {string} options.sessionKey - OpenClaw session key
50+
* @param {string} [options.sessionKey] - OpenClaw session key
951
* @param {Object} options.payload - Review-specific payload
10-
* @returns {Promise<Object>} User's decision
52+
* @param {Function} [options.onRewrite] - async (session) => updatedPayload — called when user requests a rewrite
53+
* @param {number} [options.maxRounds=10] - Max rewrite rounds before returning
54+
* @param {boolean} [options.structured=false] - Return structured result object
55+
* @param {boolean} [options.noOpen] - If true, don't open browser
56+
* @returns {Promise<Object>} User's decision (structured or legacy format)
1157
*/
12-
async function reviewAndWait({ type, sessionKey, payload }) {
58+
async function reviewAndWait({ type, sessionKey, payload, onRewrite, maxRounds = 10, structured = false, noOpen, ...rest }) {
59+
const baseUrl = getBaseUrl();
60+
const legacyMode = !onRewrite && !structured;
61+
let rounds = 0;
62+
1363
// Create review session
14-
const res = await fetch(`${AGENTCLICK_URL}/api/review`, {
64+
const body = { type, payload, ...rest };
65+
if (sessionKey) body.sessionKey = sessionKey;
66+
if (noOpen) body.noOpen = noOpen;
67+
68+
const res = await fetch(`${baseUrl}/api/review`, {
1569
method: 'POST',
1670
headers: { 'Content-Type': 'application/json' },
17-
body: JSON.stringify({ type, sessionKey, payload })
71+
body: JSON.stringify(body),
1872
});
19-
73+
2074
if (!res.ok) {
21-
throw new Error(`AgentClick error: ${res.status} ${res.statusText}`);
75+
const msg = `AgentClick error: ${res.status} ${res.statusText}`;
76+
if (legacyMode) throw new Error(msg);
77+
return { status: 'error', error: msg, sessionId: null, rounds, result: null, session: null };
2278
}
23-
79+
2480
const { sessionId } = await res.json();
2581
console.log(`[agentclick] Waiting for review: ${sessionId}`);
26-
27-
// Long-poll for result (up to 5 min)
28-
const waitRes = await fetch(`${AGENTCLICK_URL}/api/sessions/${sessionId}/wait`);
29-
30-
if (!waitRes.ok) {
31-
throw new Error(`AgentClick wait failed: ${waitRes.status}`);
82+
83+
// Review loop
84+
while (rounds < maxRounds) {
85+
rounds++;
86+
87+
let waitRes;
88+
try {
89+
waitRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/wait`);
90+
} catch (err) {
91+
const msg = `AgentClick network error during wait: ${err.message}`;
92+
if (legacyMode) throw new Error(msg);
93+
return { status: 'error', error: msg, sessionId, rounds, result: null, session: null };
94+
}
95+
96+
// Timeout
97+
if (waitRes.status === 408) {
98+
console.log(`[agentclick] Review timed out: ${sessionId}`);
99+
if (legacyMode) throw new Error('AgentClick wait failed: 408');
100+
return { status: 'timeout', sessionId, rounds, result: null, session: null, error: null };
101+
}
102+
103+
// Not found
104+
if (waitRes.status === 404) {
105+
const msg = `AgentClick session not found: ${sessionId}`;
106+
if (legacyMode) throw new Error(`AgentClick wait failed: 404`);
107+
return { status: 'error', error: msg, sessionId, rounds, result: null, session: null };
108+
}
109+
110+
// Other HTTP errors
111+
if (!waitRes.ok) {
112+
const msg = `AgentClick wait failed: ${waitRes.status}`;
113+
if (legacyMode) throw new Error(msg);
114+
return { status: 'error', error: msg, sessionId, rounds, result: null, session: null };
115+
}
116+
117+
const session = await waitRes.json();
118+
console.log(`[agentclick] Review status: ${session.status} (round ${rounds})`);
119+
120+
// Completed
121+
if (session.status === 'completed') {
122+
if (legacyMode) return session.result || session;
123+
return { status: 'completed', result: session.result || null, sessionId, rounds, session, error: null };
124+
}
125+
126+
// Rewriting
127+
if (session.status === 'rewriting') {
128+
if (!onRewrite) {
129+
// Legacy behavior: return immediately so caller can handle it
130+
if (legacyMode) return session.result || session;
131+
return { status: 'rewriting', result: session.result || null, sessionId, rounds, session, error: null };
132+
}
133+
134+
// Call rewrite callback
135+
let updatedPayload;
136+
try {
137+
updatedPayload = await onRewrite(session);
138+
} catch (err) {
139+
const msg = `onRewrite callback error: ${err.message}`;
140+
console.error(`[agentclick] ${msg}`);
141+
return { status: 'error', error: msg, sessionId, rounds, result: session.result || null, session };
142+
}
143+
144+
if (updatedPayload == null) {
145+
return { status: 'error', error: 'onRewrite returned null/undefined', sessionId, rounds, result: session.result || null, session };
146+
}
147+
148+
// PUT updated payload back
149+
try {
150+
const putRes = await fetch(`${baseUrl}/api/sessions/${sessionId}/payload`, {
151+
method: 'PUT',
152+
headers: { 'Content-Type': 'application/json' },
153+
body: JSON.stringify({ payload: updatedPayload }),
154+
});
155+
if (!putRes.ok) {
156+
const msg = `AgentClick payload update failed: ${putRes.status}`;
157+
return { status: 'error', error: msg, sessionId, rounds, result: null, session };
158+
}
159+
} catch (err) {
160+
const msg = `AgentClick network error during payload update: ${err.message}`;
161+
return { status: 'error', error: msg, sessionId, rounds, result: null, session };
162+
}
163+
164+
console.log(`[agentclick] Rewrite submitted, waiting for next review (round ${rounds})`);
165+
continue;
166+
}
167+
168+
// Unexpected status — treat as completed to avoid infinite loop
169+
if (legacyMode) return session.result || session;
170+
return { status: session.status, result: session.result || null, sessionId, rounds, session, error: null };
32171
}
33-
34-
const session = await waitRes.json();
35-
console.log(`[agentclick] Review complete: ${session.status}`);
36-
37-
return session.result || session;
172+
173+
// Max rounds exhausted
174+
console.log(`[agentclick] Max rounds (${maxRounds}) reached for: ${sessionId}`);
175+
if (legacyMode) throw new Error(`AgentClick max rewrite rounds (${maxRounds}) exceeded`);
176+
return { status: 'max_rounds', sessionId, rounds, result: null, session: null, error: null };
38177
}
39178

40-
module.exports = { reviewAndWait };
179+
module.exports = { reviewAndWait, ensureServer };

0 commit comments

Comments
 (0)