@wonanut/dsh-agent-lab 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: agent-lab
3
+ name: '@wonanut/dsh-agent-lab'
package/icon.svg ADDED
@@ -0,0 +1,26 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Agent lab">
2
+ <defs>
3
+ <linearGradient id="al-bg" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#5b7cfa"/>
5
+ <stop offset="0.62" stop-color="#2b3570"/>
6
+ <stop offset="1" stop-color="#161a2e"/>
7
+ </linearGradient>
8
+ <linearGradient id="al-glow" x1="0" y1="0" x2="0" y2="1">
9
+ <stop offset="0" stop-color="#ffffff" stop-opacity="0.30"/>
10
+ <stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
11
+ </linearGradient>
12
+ </defs>
13
+ <rect x="1" y="1" width="62" height="62" rx="16" fill="url(#al-bg)"/>
14
+ <rect x="1" y="1" width="62" height="62" rx="16" fill="url(#al-glow)"/>
15
+ <rect x="1.5" y="1.5" width="61" height="61" rx="15.5" fill="none" stroke="rgba(255,255,255,0.28)"/>
16
+ <rect x="12" y="16" width="40" height="32" rx="7" fill="#0b0e14" stroke="rgba(255,255,255,0.18)"/>
17
+ <circle cx="18.5" cy="22" r="1.6" fill="#7ee787"/>
18
+ <circle cx="23.5" cy="22" r="1.6" fill="#ffa657"/>
19
+ <circle cx="28.5" cy="22" r="1.6" fill="#ff7b72"/>
20
+ <path d="M18 31.5l5 3.9-5 3.9" fill="none" stroke="#7ee787" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
21
+ <path d="M26 39.3h8" stroke="#7ee787" stroke-width="2.2" stroke-linecap="round"/>
22
+ <circle cx="50" cy="13" r="3" fill="#d2a8ff"/>
23
+ <circle cx="50" cy="13" r="6.4" fill="none" stroke="rgba(210,168,255,0.42)" stroke-width="1"/>
24
+ <circle cx="13" cy="52" r="1.5" fill="rgba(255,255,255,0.55)"/>
25
+ <circle cx="18" cy="52" r="1.5" fill="rgba(255,255,255,0.32)"/>
26
+ </svg>
package/index.js ADDED
@@ -0,0 +1,390 @@
1
+ /**
2
+ * Host half of the Agent 实验室 bundle.
3
+ *
4
+ * The page itself is the Client half (client.js). This half exists so the
5
+ * "真实 DeepSeek" mode can call the model configured in the current DSH
6
+ * profile: it exposes two authenticated same-origin JSON routes.
7
+ *
8
+ * GET /agent-lab/status → current provider/model + sign-in availability
9
+ * POST /agent-lab/complete → one chat completion with tool schemas
10
+ *
11
+ * No credentials ever leave the Host: ctx.llm owns provider auth and the
12
+ * DeepSeek account token is resolved by the adapter. Both routes apply the
13
+ * same Connection trust checks as every other browser API.
14
+ */
15
+
16
+ function sendJson(res, status, payload) {
17
+ res.writeHead(status, {
18
+ 'content-type': 'application/json; charset=utf-8',
19
+ 'cache-control': 'no-store',
20
+ 'x-content-type-options': 'nosniff',
21
+ });
22
+ res.end(JSON.stringify(payload));
23
+ }
24
+
25
+ function readJson(req, limit) {
26
+ return new Promise((resolve, reject) => {
27
+ let size = 0;
28
+ const chunks = [];
29
+ req.on('data', (chunk) => {
30
+ size += chunk.length;
31
+ if (size > limit) {
32
+ reject(new Error('请求体过大'));
33
+ req.destroy();
34
+ return;
35
+ }
36
+ chunks.push(chunk);
37
+ });
38
+ req.on('end', () => {
39
+ try {
40
+ const text = Buffer.concat(chunks).toString('utf8');
41
+ resolve(text ? JSON.parse(text) : {});
42
+ } catch (error) {
43
+ reject(new Error('请求体不是合法 JSON'));
44
+ }
45
+ });
46
+ req.on('error', reject);
47
+ });
48
+ }
49
+
50
+ function text(value, max) {
51
+ const string = value === null || value === undefined ? '' : String(value);
52
+ return string.length > max ? string.slice(0, max) : string;
53
+ }
54
+
55
+ function asMessageContent(value) {
56
+ return [{ type: 'text', text: text(value, 20000) }];
57
+ }
58
+
59
+ /**
60
+ * Convert the plugin's simple message list into the LLM service's request
61
+ * message shapes. User messages stay request-only (no id/source); assistant
62
+ * and tool messages are durable shapes with a stable synthetic id so provider
63
+ * adapters can correlate tool calls.
64
+ */
65
+ function toRequestMessages(messages, provider, model) {
66
+ const out = [];
67
+ let index = 0;
68
+ for (const message of Array.isArray(messages) ? messages : []) {
69
+ if (!message || typeof message !== 'object') continue;
70
+ const role = message.role;
71
+ if (role === 'system') continue; /* folded into the system string by the caller */
72
+ if (role === 'user') {
73
+ out.push({ role: 'user', content: asMessageContent(message.content) });
74
+ continue;
75
+ }
76
+ index += 1;
77
+ const id = 'agent-lab-' + role + '-' + index;
78
+ if (role === 'assistant') {
79
+ const content = [];
80
+ const body = text(message.content, 20000);
81
+ if (body) content.push({ type: 'text', text: body });
82
+ const calls = Array.isArray(message.toolCalls) ? message.toolCalls : [];
83
+ for (const call of calls) {
84
+ if (!call || typeof call !== 'object') continue;
85
+ const callId = text(call.id, 120) || 'call-' + index;
86
+ content.push({
87
+ type: 'tool-call',
88
+ id: callId,
89
+ name: text(call.name, 80),
90
+ arguments: typeof call.args === 'string' ? call.args : JSON.stringify(call.args || {}),
91
+ });
92
+ }
93
+ out.push({
94
+ id,
95
+ role: 'assistant',
96
+ content,
97
+ source: { kind: 'model', provider, model },
98
+ });
99
+ continue;
100
+ }
101
+ if (role === 'tool') {
102
+ const callId = text(message.toolCallId, 120);
103
+ if (!callId) continue;
104
+ out.push({
105
+ id,
106
+ role: 'tool',
107
+ toolCallId: callId,
108
+ source: { kind: 'tool', callId },
109
+ content: asMessageContent(message.content),
110
+ ...(message.isError ? { isError: true } : {}),
111
+ });
112
+ }
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function normalizeTools(tools) {
118
+ if (!Array.isArray(tools)) return undefined;
119
+ const out = [];
120
+ for (const tool of tools) {
121
+ if (!tool || typeof tool.name !== 'string' || !tool.name) continue;
122
+ out.push({
123
+ name: tool.name,
124
+ description: text(tool.description, 2000),
125
+ parameters: tool.parameters && typeof tool.parameters === 'object' ? tool.parameters : { type: 'object', properties: {} },
126
+ });
127
+ }
128
+ return out.length ? out : undefined;
129
+ }
130
+
131
+ /** Combine the plugin's system messages into the single system string. */
132
+ function systemOf(body, messages) {
133
+ const parts = [];
134
+ if (typeof body.system === 'string' && body.system.trim()) parts.push(body.system.trim());
135
+ for (const message of Array.isArray(messages) ? messages : []) {
136
+ if (message && message.role === 'system' && typeof message.content === 'string' && message.content.trim()) {
137
+ parts.push(message.content.trim());
138
+ }
139
+ }
140
+ return parts.join('\n\n');
141
+ }
142
+
143
+ /** Turn provider-neutral failure codes into a message the learner can act on. */
144
+ function explainFailure(failure) {
145
+ const code = failure && typeof failure.code === 'string' ? failure.code : 'UNKNOWN';
146
+ const message = failure && typeof failure.message === 'string' ? failure.message : '模型调用失败';
147
+ if (code === 'ACCOUNT_SIGN_IN_REQUIRED' || code === 'MISSING_CREDENTIAL') {
148
+ return { code, message: '当前 DeepSeek 账号未登录或登录已失效,请先在 DSH 设置中登录账号。' };
149
+ }
150
+ if (code === 'ACCOUNT_QUOTA' || code === 'QUOTA') {
151
+ return { code, message: 'DeepSeek 账号额度不足,请充值或更换模型后再试。' };
152
+ }
153
+ if (code === 'CONTEXT_WINDOW_EXCEEDED') {
154
+ return { code, message: '上下文超出模型窗口,请先压缩上下文再试。' };
155
+ }
156
+ if (code === 'RATE_LIMIT') return { code, message: '请求被限流,请稍后再试。' };
157
+ if (code === 'MODEL_UNSUPPORTED' || code === 'CLIENT_UPGRADE_REQUIRED') {
158
+ return { code, message: '当前 DSH 版本或模型配置不支持该模型,请在设置里更换模型。' };
159
+ }
160
+ return { code, message };
161
+ }
162
+
163
+ /**
164
+ * Wait for the Web carrier before registering routes: at process startup a
165
+ * plugin may be activated before webServer is available, and a missing
166
+ * service at apply time would silently skip both routes (they would only
167
+ * appear after a manual plugin toggle).
168
+ */
169
+ export const inject = ['webServer'];
170
+
171
+ export function apply(ctx) {
172
+ const webServer = ctx.get('webServer');
173
+ const connection = ctx.get('connection');
174
+ if (!webServer) return;
175
+
176
+ const reject = (req) => {
177
+ if (!connection || typeof connection.requestRejection !== 'function') return 503;
178
+ return connection.requestRejection({ headers: req.headers });
179
+ };
180
+
181
+ const statusHandler = async (req, res) => {
182
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
183
+ sendJson(res, 405, { ok: false, error: { message: '只支持 GET' } });
184
+ return;
185
+ }
186
+ const rejection = reject(req);
187
+ if (rejection) {
188
+ sendJson(res, rejection, { ok: false, error: { message: '未认证的请求' } });
189
+ return;
190
+ }
191
+ const llm = ctx.get('llm');
192
+ const agentDefaultModel = ctx.get('agentDefaultModel');
193
+ const selection = agentDefaultModel ? agentDefaultModel.currentSelection() : null;
194
+ const provider = selection && typeof selection.provider === 'string' ? selection.provider : '';
195
+ const model = selection && typeof selection.model === 'string' ? selection.model : '';
196
+ let signedIn = null;
197
+ if (provider === 'deepseek-account') {
198
+ const account = ctx.get('deepseekAccount');
199
+ if (account && typeof account.getState === 'function') {
200
+ try {
201
+ const state = await account.getState();
202
+ signedIn = state && state.status === 'credential-stored';
203
+ } catch (error) {
204
+ signedIn = false;
205
+ }
206
+ }
207
+ }
208
+ let modelName = model;
209
+ let models = [];
210
+ let contextWindow = null;
211
+ let maxOutputTokens = null;
212
+ if (llm && provider) {
213
+ try {
214
+ const list = await llm.listModels(provider);
215
+ models = list.map((item) => ({ id: item.id, name: item.name || item.id }));
216
+ const hit = list.find((item) => item.id === model);
217
+ if (hit && hit.name) modelName = hit.name;
218
+ } catch (error) {
219
+ /* 模型目录不可用时只返回 id */
220
+ }
221
+ try {
222
+ const resolved = await llm.resolveModelInfo(provider, model);
223
+ if (resolved && resolved.context && Number.isFinite(resolved.context.contextWindow)) {
224
+ contextWindow = resolved.context.contextWindow;
225
+ }
226
+ if (resolved && Number.isFinite(resolved.defaultMaxTokens)) {
227
+ maxOutputTokens = resolved.defaultMaxTokens;
228
+ }
229
+ } catch (error) {
230
+ /* 能力解析失败时窗口未知 */
231
+ }
232
+ }
233
+ sendJson(res, 200, {
234
+ ok: Boolean(llm && provider && model),
235
+ provider,
236
+ model,
237
+ modelName,
238
+ reasoningEffort: selection && selection.reasoningEffort ? String(selection.reasoningEffort) : null,
239
+ signedIn,
240
+ models,
241
+ contextWindow,
242
+ maxOutputTokens,
243
+ });
244
+ };
245
+
246
+ const completeHandler = async (req, res) => {
247
+ if (req.method !== 'POST') {
248
+ sendJson(res, 405, { ok: false, error: { message: '只支持 POST' } });
249
+ return;
250
+ }
251
+ const rejection = reject(req);
252
+ if (rejection) {
253
+ sendJson(res, rejection, { ok: false, error: { message: '未认证的请求' } });
254
+ return;
255
+ }
256
+ const llm = ctx.get('llm');
257
+ const agentDefaultModel = ctx.get('agentDefaultModel');
258
+ if (!llm || !agentDefaultModel) {
259
+ sendJson(res, 503, { ok: false, error: { code: 'LLM_UNAVAILABLE', message: 'Host 侧没有可用的 llm 服务。' } });
260
+ return;
261
+ }
262
+ let body;
263
+ try {
264
+ body = await readJson(req, 256 * 1024);
265
+ } catch (error) {
266
+ sendJson(res, 400, { ok: false, error: { code: 'BAD_REQUEST', message: error.message || '请求体不合法' } });
267
+ return;
268
+ }
269
+ const selection = agentDefaultModel.currentSelection();
270
+ const provider = selection && typeof selection.provider === 'string' ? selection.provider : '';
271
+ const model = selection && typeof selection.model === 'string' ? selection.model : '';
272
+ if (!provider || !model) {
273
+ sendJson(res, 503, { ok: false, error: { code: 'NO_MODEL', message: '当前 profile 没有选择默认模型。' } });
274
+ return;
275
+ }
276
+ const messages = toRequestMessages(body.messages, provider, model);
277
+ if (!messages.length) {
278
+ sendJson(res, 400, { ok: false, error: { code: 'NO_MESSAGES', message: '请求里没有可发送的消息。' } });
279
+ return;
280
+ }
281
+ const maxTokens = Math.max(64, Math.min(2048, Number(body.maxTokens) || 800));
282
+ const temperature = Math.max(0, Math.min(1.5, Number(body.temperature)));
283
+ const controller = new AbortController();
284
+ const timer = setTimeout(() => controller.abort(), 120000);
285
+ const onClose = () => {
286
+ if (!res.writableEnded) controller.abort();
287
+ };
288
+ res.on('close', onClose);
289
+ const startedAt = Date.now();
290
+ let textOut = '';
291
+ const toolCalls = new Map();
292
+ let usage = null;
293
+ let finish = null;
294
+ try {
295
+ const stream = llm.stream({
296
+ provider,
297
+ model,
298
+ system: systemOf(body, body.messages) || undefined,
299
+ messages,
300
+ tools: normalizeTools(body.tools),
301
+ maxTokens,
302
+ ...(Number.isFinite(temperature) ? { temperature } : {}),
303
+ ...(body.thinking === false
304
+ ? { reasoningEffort: 'off' }
305
+ : selection.reasoningEffort
306
+ ? { reasoningEffort: selection.reasoningEffort }
307
+ : {}),
308
+ signal: controller.signal,
309
+ });
310
+ for await (const chunk of stream) {
311
+ if (chunk.type === 'text-delta') {
312
+ textOut += chunk.text;
313
+ } else if (chunk.type === 'tool-call-delta') {
314
+ const current = toolCalls.get(chunk.index) || { id: chunk.id, name: '', args: '' };
315
+ if (chunk.name) current.name = chunk.name;
316
+ current.args += chunk.argumentsDelta || '';
317
+ toolCalls.set(chunk.index, current);
318
+ } else if (chunk.type === 'block-end') {
319
+ const block = chunk.block;
320
+ if (block && block.type === 'tool-call') {
321
+ toolCalls.set(chunk.index, {
322
+ id: block.id,
323
+ name: block.name,
324
+ args: typeof block.arguments === 'string' ? block.arguments : JSON.stringify(block.arguments || {}),
325
+ });
326
+ } else if (block && block.type === 'text' && !textOut) {
327
+ textOut = block.text;
328
+ }
329
+ } else if (chunk.type === 'usage') {
330
+ usage = chunk.usage;
331
+ } else if (chunk.type === 'finish') {
332
+ finish = chunk.reason;
333
+ }
334
+ }
335
+ if (finish && (finish.kind === 'error' || finish.kind === 'aborted')) {
336
+ const explained = explainFailure(finish.failure || {});
337
+ sendJson(res, finish.kind === 'aborted' ? 504 : 502, {
338
+ ok: false,
339
+ error: explained,
340
+ provider,
341
+ model,
342
+ latencyMs: Date.now() - startedAt,
343
+ });
344
+ return;
345
+ }
346
+ const calls = [];
347
+ for (const call of toolCalls.values()) {
348
+ let args = call.args;
349
+ try {
350
+ args = call.args ? JSON.parse(call.args) : {};
351
+ } catch (error) {
352
+ /* 保留原始字符串,客户端工具会返回参数错误 */
353
+ }
354
+ calls.push({ id: call.id, name: call.name, args });
355
+ }
356
+ sendJson(res, 200, {
357
+ ok: true,
358
+ text: textOut,
359
+ toolCalls: calls,
360
+ usage,
361
+ finish: finish ? finish.kind : 'stop',
362
+ provider,
363
+ model,
364
+ latencyMs: Date.now() - startedAt,
365
+ });
366
+ } catch (error) {
367
+ const aborted = controller.signal.aborted;
368
+ sendJson(res, aborted ? 504 : 502, {
369
+ ok: false,
370
+ error: {
371
+ code: aborted ? 'ABORTED' : 'TRANSPORT',
372
+ message: aborted ? '模型请求超时或被取消。' : error && error.message ? error.message : '模型调用失败',
373
+ },
374
+ provider,
375
+ model,
376
+ latencyMs: Date.now() - startedAt,
377
+ });
378
+ } finally {
379
+ clearTimeout(timer);
380
+ res.off('close', onClose);
381
+ }
382
+ };
383
+
384
+ ctx.effect(() =>
385
+ webServer.register({ kind: 'exact', path: '/agent-lab/status', handler: statusHandler }),
386
+ );
387
+ ctx.effect(() =>
388
+ webServer.register({ kind: 'exact', path: '/agent-lab/complete', handler: completeHandler }),
389
+ );
390
+ }
package/locale/en.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Agent lab",
4
+ "description": "A miniature agent in a simulated TUI: watch input flow through context, LLM, tools and memory, with compaction, session management, source code and principles."
5
+ }
6
+ }
package/locale/zh.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "meta": {
3
+ "title": "Agent 实验室",
4
+ "description": "在模拟 TUI 中体验一个迷你 Agent:输入如何经过上下文、LLM、工具与记忆,支持上下文压缩、会话管理和源码/原理阅读。"
5
+ }
6
+ }
package/package.json CHANGED
@@ -1,6 +1,68 @@
1
1
  {
2
2
  "name": "@wonanut/dsh-agent-lab",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "description": "Agent 实验室:在模拟 TUI 中体验迷你 Agent 的输入、上下文、LLM、工具、记忆与压缩,并查看实现源码与原理。",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./client": "./client.js",
10
+ "./locale/*.json": "./locale/*.json",
11
+ "./icon": "./icon.svg"
12
+ },
13
+ "files": [
14
+ "index.js",
15
+ "client.js",
16
+ "cordis.patch.yml",
17
+ "locale/*.json",
18
+ "icon.svg",
19
+ "src/*.js",
20
+ "build.mjs",
21
+ "README.md",
22
+ "LICENSE",
23
+ "CONTRACT.md",
24
+ "test/*.mjs",
25
+ "preview/*.png"
26
+ ],
27
+ "dsh": {
28
+ "bundle": {
29
+ "patch": "./cordis.patch.yml"
30
+ },
31
+ "client": {
32
+ "platform": "web",
33
+ "inject": [
34
+ "@deepseek-ai/dsh-client-ui-layout",
35
+ "@deepseek-ai/dsh-client-ui-sidebar",
36
+ "@deepseek-ai/dsh-client-ui-workspace"
37
+ ],
38
+ "immediately": true
39
+ }
40
+ },
41
+ "license": "MIT",
42
+ "author": "wonanut",
43
+ "keywords": [
44
+ "dsh",
45
+ "deepseek-harness",
46
+ "plugin",
47
+ "agent",
48
+ "agent-lab",
49
+ "learning",
50
+ "tui",
51
+ "llm"
52
+ ],
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/wonanut/dsh-plugins.git",
56
+ "directory": "agent-lab-plugin"
57
+ },
58
+ "homepage": "https://github.com/wonanut/dsh-plugins/tree/main/agent-lab-plugin",
59
+ "bugs": {
60
+ "url": "https://github.com/wonanut/dsh-plugins/issues"
61
+ },
62
+ "engines": {
63
+ "dsh": ">=0.2.2-rc.1"
64
+ },
65
+ "publishConfig": {
66
+ "access": "public"
67
+ }
68
+ }
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file