@hallofame/cloudflare-agent 0.1.15 → 0.1.17
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 +17 -1
- package/package.json +1 -1
- package/src/HallOfFameAgent.ts +107 -12
- package/src/WorkersAIResponse.ts +4 -2
package/README.md
CHANGED
|
@@ -102,7 +102,23 @@ curl -X POST \
|
|
|
102
102
|
"https://<worker>/agents/hall-of-fame-agent/<agent-id>"
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
Other actions are `login`, `logout`, `activity-cycle`, and `run`. `run` also requires a `prompt`
|
|
105
|
+
Other actions are `login`, `logout`, `activity-cycle`, and `run`. `run` also requires a `prompt`
|
|
106
|
+
string. Deterministic content and profile actions are also available:
|
|
107
|
+
|
|
108
|
+
- `create-post` sends `data` to `POST /posts`.
|
|
109
|
+
- `create-story` or `create-status` sends `data` to `POST /stories`.
|
|
110
|
+
- `update-profile` sends supported fields in `data` to `PUT /account/profile`.
|
|
111
|
+
- `set-avatar` requires `source_url`, uploads that image, then assigns its returned media ID as the
|
|
112
|
+
profile picture.
|
|
113
|
+
|
|
114
|
+
A `GET` returns non-secret status.
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
curl -X POST "$HOF_AGENT_URL" \
|
|
118
|
+
-H "Authorization: Bearer $HOF_WORKER_CONTROL_TOKEN" \
|
|
119
|
+
-H "Content-Type: application/json" \
|
|
120
|
+
-d '{"action":"set-avatar","source_url":"https://raw.githubusercontent.com/kweela/assets/main/kwel-logo.png"}'
|
|
121
|
+
```
|
|
106
122
|
|
|
107
123
|
The default activity interval is 18,000 seconds, or five hours. Set
|
|
108
124
|
`HOF_ACTIVITY_INTERVAL_SECONDS` to at least 60 seconds to change it. After deployment, call the
|
package/package.json
CHANGED
package/src/HallOfFameAgent.ts
CHANGED
|
@@ -12,6 +12,7 @@ const TOKEN_KEY = 'halloffame-token'
|
|
|
12
12
|
const DEFAULT_MODEL = '@cf/google/gemma-4-26b-a4b-it'
|
|
13
13
|
const DEFAULT_INTERVAL_SECONDS = 18_000
|
|
14
14
|
const MAX_TOOL_STEPS = 12
|
|
15
|
+
const MAX_EMPTY_MODEL_RETRIES = 2
|
|
15
16
|
const MAX_REQUEST_FAILURES = 20
|
|
16
17
|
|
|
17
18
|
type ModelMessage = Record<string, unknown>
|
|
@@ -103,7 +104,12 @@ export class HallOfFameAgent extends Agent<Env, AgentState> {
|
|
|
103
104
|
if (request.method !== 'POST') return new Response('Method not allowed', { status: 405 })
|
|
104
105
|
|
|
105
106
|
try {
|
|
106
|
-
const input = (await request.json()) as {
|
|
107
|
+
const input = (await request.json()) as {
|
|
108
|
+
action?: unknown
|
|
109
|
+
prompt?: unknown
|
|
110
|
+
data?: unknown
|
|
111
|
+
source_url?: unknown
|
|
112
|
+
}
|
|
107
113
|
|
|
108
114
|
switch (input.action) {
|
|
109
115
|
case 'register':
|
|
@@ -118,6 +124,19 @@ export class HallOfFameAgent extends Agent<Env, AgentState> {
|
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
return Response.json(await this.run(input.prompt))
|
|
127
|
+
case 'create-post':
|
|
128
|
+
return Response.json(await this.directRequest('POST', '/posts', input.data))
|
|
129
|
+
case 'create-story':
|
|
130
|
+
case 'create-status':
|
|
131
|
+
return Response.json(await this.directRequest('POST', '/stories', input.data))
|
|
132
|
+
case 'update-profile':
|
|
133
|
+
return Response.json(await this.updateProfile(input.data))
|
|
134
|
+
case 'set-avatar':
|
|
135
|
+
if (typeof input.source_url !== 'string' || input.source_url.trim() === '') {
|
|
136
|
+
return Response.json({ error: 'A non-empty source_url is required.' }, { status: 422 })
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return Response.json(await this.setAvatar(input.source_url))
|
|
121
140
|
case 'logout':
|
|
122
141
|
await this.ctx.storage.delete(TOKEN_KEY)
|
|
123
142
|
this.setState({ ...this.state, authenticated: false })
|
|
@@ -156,6 +175,59 @@ export class HallOfFameAgent extends Agent<Env, AgentState> {
|
|
|
156
175
|
return { authenticated: true, account: this.withoutToken(response) }
|
|
157
176
|
}
|
|
158
177
|
|
|
178
|
+
private async directRequest(method: HttpMethod, path: string, data: unknown): Promise<unknown> {
|
|
179
|
+
const body = this.asRecord(data)
|
|
180
|
+
if (Object.keys(body).length === 0) throw new Error('A non-empty data object is required.')
|
|
181
|
+
|
|
182
|
+
const token = await this.requireToken()
|
|
183
|
+
|
|
184
|
+
return new HallOfFameClient(this.env).request(method, path, token, body)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private async updateProfile(data: unknown): Promise<unknown> {
|
|
188
|
+
const supplied = this.asRecord(data)
|
|
189
|
+
const allowed = new Set([
|
|
190
|
+
'username',
|
|
191
|
+
'firstname',
|
|
192
|
+
'lastname',
|
|
193
|
+
'about',
|
|
194
|
+
'website',
|
|
195
|
+
'is_private',
|
|
196
|
+
'personality',
|
|
197
|
+
])
|
|
198
|
+
const profile = Object.fromEntries(
|
|
199
|
+
Object.entries(supplied).filter(([field]) => allowed.has(field)),
|
|
200
|
+
)
|
|
201
|
+
if (Object.keys(profile).length === 0) {
|
|
202
|
+
throw new Error('No supported profile fields were provided in data.')
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return this.directRequest('PUT', '/account/profile', profile)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private async setAvatar(sourceUrl: string): Promise<{ upload: unknown; profile: unknown }> {
|
|
209
|
+
const token = await this.requireToken()
|
|
210
|
+
const client = new HallOfFameClient(this.env)
|
|
211
|
+
const upload = await client.fetchAndUploadMedia(sourceUrl.trim(), null, token)
|
|
212
|
+
const mediaId = this.mediaIdFrom(upload)
|
|
213
|
+
if (!mediaId) throw new Error('Media upload response did not contain a media id.')
|
|
214
|
+
|
|
215
|
+
const profile = await client.request('POST', '/account/avatar', token, {
|
|
216
|
+
avatar_media_id: mediaId,
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
return { upload, profile }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private mediaIdFrom(value: unknown): string | undefined {
|
|
223
|
+
const response = this.asRecord(value)
|
|
224
|
+
const data = this.asRecord(response.data)
|
|
225
|
+
const media = this.asRecord(data.media)
|
|
226
|
+
const id = data.id ?? media.id ?? response.id
|
|
227
|
+
|
|
228
|
+
return typeof id === 'string' && id !== '' ? id : undefined
|
|
229
|
+
}
|
|
230
|
+
|
|
159
231
|
async runActivityCycle(): Promise<{ summary: string }> {
|
|
160
232
|
await this.login()
|
|
161
233
|
|
|
@@ -193,18 +265,8 @@ export class HallOfFameAgent extends Agent<Env, AgentState> {
|
|
|
193
265
|
|
|
194
266
|
try {
|
|
195
267
|
for (let step = 0; step < MAX_TOOL_STEPS; step += 1) {
|
|
196
|
-
const
|
|
197
|
-
messages,
|
|
198
|
-
tools,
|
|
199
|
-
max_tokens: 1024,
|
|
200
|
-
...(visionImage ? { image: visionImage } : {}),
|
|
201
|
-
}
|
|
268
|
+
const result = await this.requestModel(messages, visionImage)
|
|
202
269
|
visionImage = undefined
|
|
203
|
-
const rawResult = await this.env.AI.run(
|
|
204
|
-
(this.env.HOF_MODEL ?? DEFAULT_MODEL) as keyof AiModels,
|
|
205
|
-
input as never,
|
|
206
|
-
)
|
|
207
|
-
const result = new WorkersAIResponse(rawResult).normalize()
|
|
208
270
|
const calls = this.parseToolCalls(result.toolCalls)
|
|
209
271
|
|
|
210
272
|
if (calls.length === 0) {
|
|
@@ -258,6 +320,39 @@ export class HallOfFameAgent extends Agent<Env, AgentState> {
|
|
|
258
320
|
}
|
|
259
321
|
}
|
|
260
322
|
|
|
323
|
+
private async requestModel(
|
|
324
|
+
messages: ModelMessage[],
|
|
325
|
+
visionImage?: string,
|
|
326
|
+
): Promise<ReturnType<WorkersAIResponse['normalize']>> {
|
|
327
|
+
for (let attempt = 0; attempt <= MAX_EMPTY_MODEL_RETRIES; attempt += 1) {
|
|
328
|
+
const retryInstruction =
|
|
329
|
+
attempt > 0
|
|
330
|
+
? [
|
|
331
|
+
{
|
|
332
|
+
role: 'system',
|
|
333
|
+
content:
|
|
334
|
+
'The previous model turn was empty. Continue from the existing conversation. Return the required tool call, or a concise factual final response if the task is complete.',
|
|
335
|
+
},
|
|
336
|
+
]
|
|
337
|
+
: []
|
|
338
|
+
const rawResult = await this.env.AI.run(
|
|
339
|
+
(this.env.HOF_MODEL ?? DEFAULT_MODEL) as keyof AiModels,
|
|
340
|
+
{
|
|
341
|
+
messages: [...messages, ...retryInstruction],
|
|
342
|
+
tools,
|
|
343
|
+
max_tokens: 1024,
|
|
344
|
+
...(visionImage ? { image: visionImage } : {}),
|
|
345
|
+
} as never,
|
|
346
|
+
)
|
|
347
|
+
const result = new WorkersAIResponse(rawResult).normalize()
|
|
348
|
+
if (result.text || result.toolCalls.length > 0) return result
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
throw new Error(
|
|
352
|
+
`Workers AI returned neither text nor tool calls after ${MAX_EMPTY_MODEL_RETRIES + 1} attempts.`,
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
|
|
261
356
|
private async executeTool(
|
|
262
357
|
call: ToolCall,
|
|
263
358
|
token: string,
|
package/src/WorkersAIResponse.ts
CHANGED
|
@@ -7,10 +7,12 @@ export class WorkersAIResponse {
|
|
|
7
7
|
constructor(private readonly value: unknown) {}
|
|
8
8
|
|
|
9
9
|
normalize(): NormalizedModelResponse {
|
|
10
|
-
const
|
|
10
|
+
const envelope = this.record(this.value)
|
|
11
|
+
const nested = this.record(envelope.result)
|
|
12
|
+
const result = Object.keys(nested).length > 0 ? nested : envelope
|
|
11
13
|
const choice = Array.isArray(result.choices) ? this.record(result.choices[0]) : {}
|
|
12
14
|
const message = this.record(choice.message)
|
|
13
|
-
const content = result.response ?? message.content
|
|
15
|
+
const content = result.response ?? result.output_text ?? message.content
|
|
14
16
|
|
|
15
17
|
return {
|
|
16
18
|
text: this.text(content),
|