tinywebui 0.1.4 → 0.1.5
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 +20 -13
- package/bin/tinywebui.js +69 -53
- package/package.json +25 -8
- package/public/app.js +40 -34
- package/public/chat/composer.js +1 -1
- package/public/chat/sidebar.js +8 -5
- package/public/index.html +88 -53
- package/public/panels/admin.js +40 -1
- package/public/panels/model-editor.js +336 -0
- package/public/panels/panels.js +37 -12
- package/public/panels/settings-home.js +125 -0
- package/public/panels/settings.js +124 -36
- package/public/shared/attachment_note.js +7 -1
- package/public/shared/events.js +1 -0
- package/public/shared/features.js +1 -0
- package/public/shared/origins.js +1 -0
- package/public/shared/usage.js +16 -1
- package/public/styles.css +84 -21
- package/src/access/auth.js +155 -155
- package/src/access/auth_gate.js +39 -38
- package/src/access/email.js +17 -0
- package/src/access/policy.js +123 -136
- package/src/audit.js +1 -1
- package/src/automations/automation.js +135 -123
- package/src/automations/scheduler.js +138 -127
- package/src/chat/compact.js +149 -156
- package/src/chat/llm.js +380 -382
- package/src/chat/runs.js +162 -151
- package/src/chat/tool_executor.js +229 -218
- package/src/cli.js +535 -433
- package/src/config/approval.js +35 -29
- package/src/config/config.js +546 -454
- package/src/config/data_dir.js +24 -0
- package/src/config/models.js +191 -115
- package/src/config/refs.js +32 -0
- package/src/config/secrets.js +153 -0
- package/src/files/documents.js +57 -68
- package/src/files/images.js +30 -31
- package/src/harness/agent.js +60 -60
- package/src/harness/attribution.js +66 -58
- package/src/harness/checkpoint.js +67 -62
- package/src/harness/history.js +28 -23
- package/src/harness/prompt.js +40 -57
- package/src/harness/provider.js +284 -283
- package/src/harness/types.js +1 -0
- package/src/http.js +90 -72
- package/src/log.js +11 -12
- package/src/mcp.js +398 -374
- package/src/retrieval/embedding.js +228 -240
- package/src/retrieval/retrieval.js +182 -173
- package/src/routes/admin.js +178 -138
- package/src/routes/auth.js +184 -111
- package/src/routes/automations.js +99 -82
- package/src/routes/chats.js +240 -218
- package/src/routes/documents.js +130 -120
- package/src/routes/library.js +43 -46
- package/src/routes/mcp.js +49 -49
- package/src/routes/settings.js +127 -122
- package/src/routes/types.js +1 -0
- package/src/server.js +426 -415
- package/src/store/automations.js +106 -106
- package/src/store/chats.js +217 -242
- package/src/store/documents.js +181 -185
- package/src/store/embeddings.js +52 -54
- package/src/store/index.js +95 -49
- package/src/store/messages.js +55 -80
- package/src/store/schema.js +128 -107
- package/src/store/scope.js +18 -17
- package/src/store/search.js +25 -27
- package/src/store/turns.js +53 -53
- package/src/store/usage.js +343 -269
- package/src/store/users.js +162 -87
- package/src/store/wire.js +50 -41
- package/src/tools/ask_tool.js +68 -68
- package/src/tools/chat_search_tool.js +38 -44
- package/src/tools/context_tool.js +131 -138
- package/src/tools/document_tool.js +73 -78
- package/src/tools/mcp_tool.js +274 -232
- package/src/tools/task_tool.js +53 -50
- package/src/tools/types.js +1 -0
- package/public/panels/connectors.js +0 -184
- package/src/set-password.js +0 -43
package/src/store/users.js
CHANGED
|
@@ -1,102 +1,177 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
|
|
3
2
|
// Users, their preferences, and login sessions.
|
|
4
3
|
// One area of the Store; see index.js.
|
|
5
|
-
|
|
6
4
|
export class UserStore {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
5
|
+
db;
|
|
6
|
+
constructor(db) {
|
|
7
|
+
this.db = db;
|
|
8
|
+
}
|
|
9
|
+
getPrefs(userId) {
|
|
10
|
+
const out = {};
|
|
11
|
+
for (const r of this.db.prepare('SELECT key, value FROM user_prefs WHERE user_id = ?').all(userId))
|
|
12
|
+
out[r.key] = r.value;
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
setPref(userId, key, value) {
|
|
16
|
+
if (value == null)
|
|
17
|
+
this.db.prepare('DELETE FROM user_prefs WHERE user_id = ? AND key = ?').run(userId, key);
|
|
18
|
+
else
|
|
19
|
+
this.db.prepare(`INSERT INTO user_prefs (user_id, key, value) VALUES (?, ?, ?)
|
|
21
20
|
ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value`).run(userId, key, String(value));
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
}
|
|
22
|
+
get(id) {
|
|
23
|
+
return this.db.prepare('SELECT * FROM users WHERE id = ?').get(id) || null;
|
|
24
|
+
}
|
|
25
|
+
count() {
|
|
26
|
+
return this.db.prepare('SELECT COUNT(*) AS n FROM users').get().n;
|
|
27
|
+
}
|
|
28
|
+
localCount() {
|
|
29
|
+
return this.db.prepare('SELECT COUNT(*) AS n FROM users WHERE password_hash IS NOT NULL').get().n;
|
|
30
|
+
}
|
|
31
|
+
byEmail(email) {
|
|
32
|
+
return this.db.prepare('SELECT * FROM users WHERE email = ?').get(email) || null;
|
|
33
|
+
}
|
|
34
|
+
list() {
|
|
35
|
+
return this.db.prepare(`
|
|
30
36
|
SELECT u.id, u.email, u.name, u.role, u.status, u.external_id, u.created_at, u.last_login_at,
|
|
31
37
|
(SELECT COUNT(*) FROM chats c WHERE c.user_id = u.id) AS chat_count
|
|
32
38
|
FROM users u ORDER BY u.created_at ASC
|
|
33
39
|
`).all();
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
40
|
+
}
|
|
41
|
+
/** Admin-side changes only: role in (admin,user), status in (pending,approved,disabled). */
|
|
42
|
+
update(id, { role, status } = {}) {
|
|
43
|
+
const sets = [];
|
|
44
|
+
const vals = [];
|
|
45
|
+
if (role !== undefined) {
|
|
46
|
+
sets.push('role = ?');
|
|
47
|
+
vals.push(role);
|
|
48
|
+
}
|
|
49
|
+
if (status !== undefined) {
|
|
50
|
+
sets.push('status = ?');
|
|
51
|
+
vals.push(status);
|
|
52
|
+
if (status === 'approved') {
|
|
53
|
+
sets.push('approved_at = COALESCE(approved_at, ?)');
|
|
54
|
+
vals.push(Date.now());
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (sets.length)
|
|
58
|
+
this.db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id);
|
|
59
|
+
return this.get(id);
|
|
60
|
+
}
|
|
61
|
+
byExternalId(externalId) {
|
|
62
|
+
return this.db.prepare('SELECT * FROM users WHERE external_id = ?').get(externalId) || null;
|
|
63
|
+
}
|
|
64
|
+
/** Whether another account already holds this email (emails are unique). */
|
|
65
|
+
emailTaken(email, exceptId) {
|
|
66
|
+
return Boolean(this.db.prepare('SELECT 1 FROM users WHERE email = ? AND id != ?').get(email, exceptId));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Creates the user a gateway vouched for, unless one with that external id
|
|
70
|
+
* exists. The unique index makes this atomic: concurrent first requests race
|
|
71
|
+
* to insert, exactly one wins. Returns { user, created }.
|
|
72
|
+
*/
|
|
73
|
+
provision({ externalId, role, status, now = Date.now() }) {
|
|
74
|
+
const created = this.db.prepare(`
|
|
64
75
|
INSERT INTO users (id, external_id, role, status, created_at, approved_at, last_login_at)
|
|
65
76
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
66
77
|
ON CONFLICT(external_id) DO NOTHING
|
|
67
78
|
`).run(randomUUID(), externalId, role, status, now, status === 'approved' ? now : null, now).changes > 0;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
this.db.prepare(`INSERT INTO users (id, role, status, created_at, approved_at)
|
|
79
|
+
return { user: this.byExternalId(externalId), created };
|
|
80
|
+
}
|
|
81
|
+
/** Gateway-side sync of identity metadata; only these columns can be set. */
|
|
82
|
+
sync(id, fields) {
|
|
83
|
+
const allowed = ['email', 'name', 'role', 'status', 'last_login_at'];
|
|
84
|
+
const keys = Object.keys(fields).filter((k) => allowed.includes(k));
|
|
85
|
+
if (keys.length)
|
|
86
|
+
this.db.prepare(`UPDATE users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => fields[k]), id);
|
|
87
|
+
return this.get(id);
|
|
88
|
+
}
|
|
89
|
+
/** The single approved admin account behind a 'single' password. */
|
|
90
|
+
ensureOwner(id) {
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
this.db.prepare(`INSERT INTO users (id, role, status, created_at, approved_at)
|
|
83
93
|
VALUES (?, 'admin', 'approved', ?, ?) ON CONFLICT(id) DO NOTHING`).run(id, now, now);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
94
|
+
}
|
|
95
|
+
removePasswordless(id) {
|
|
96
|
+
this.db.prepare('DELETE FROM users WHERE id = ? AND password_hash IS NULL AND external_id IS NULL').run(id);
|
|
97
|
+
}
|
|
98
|
+
/** Atomically claims a fresh local installation. Exactly one caller wins. */
|
|
99
|
+
createFirstLocal({ email, passwordHash, now = Date.now() }) {
|
|
100
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
101
|
+
try {
|
|
102
|
+
if (this.localCount() !== 0) {
|
|
103
|
+
this.db.exec('ROLLBACK');
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const id = randomUUID();
|
|
107
|
+
this.db.prepare(`INSERT INTO users
|
|
108
|
+
(id, email, password_hash, role, status, created_at, approved_at, last_login_at)
|
|
109
|
+
VALUES (?, ?, ?, 'admin', 'approved', ?, ?, ?)`)
|
|
110
|
+
.run(id, email, passwordHash, now, now, now);
|
|
111
|
+
this.db.exec('COMMIT');
|
|
112
|
+
return this.get(id);
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
this.db.exec('ROLLBACK');
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
createInvite({ id, tokenHash, email, createdBy, createdAt = Date.now(), expiresAt }) {
|
|
120
|
+
this.db.prepare(`INSERT INTO invitations
|
|
121
|
+
(id, token_hash, email, created_by, created_at, expires_at)
|
|
122
|
+
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
123
|
+
.run(id, tokenHash, email, createdBy, createdAt, expiresAt);
|
|
124
|
+
return this.db.prepare('SELECT id, email, created_by, created_at, expires_at FROM invitations WHERE id = ?').get(id);
|
|
125
|
+
}
|
|
126
|
+
listInvites(now = Date.now()) {
|
|
127
|
+
return this.db.prepare(`SELECT id, email, created_by, created_at, expires_at
|
|
128
|
+
FROM invitations WHERE used_at IS NULL AND expires_at > ? ORDER BY created_at DESC`).all(now);
|
|
129
|
+
}
|
|
130
|
+
revokeInvite(id) {
|
|
131
|
+
return this.db.prepare('DELETE FROM invitations WHERE id = ? AND used_at IS NULL').run(id).changes > 0;
|
|
132
|
+
}
|
|
133
|
+
/** Claims a valid invitation and creates its approved local user in one transaction. */
|
|
134
|
+
acceptInvite({ tokenHash, email, passwordHash, now = Date.now() }) {
|
|
135
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
136
|
+
try {
|
|
137
|
+
const invite = this.db.prepare('SELECT * FROM invitations WHERE token_hash = ?').get(tokenHash);
|
|
138
|
+
if (!invite || invite.used_at !== null || invite.expires_at <= now) {
|
|
139
|
+
this.db.exec('ROLLBACK');
|
|
140
|
+
return { error: 'invalid_or_expired' };
|
|
141
|
+
}
|
|
142
|
+
if (invite.email && invite.email !== email) {
|
|
143
|
+
this.db.exec('ROLLBACK');
|
|
144
|
+
return { error: 'email_mismatch' };
|
|
145
|
+
}
|
|
146
|
+
if (this.byEmail(email)) {
|
|
147
|
+
this.db.exec('ROLLBACK');
|
|
148
|
+
return { error: 'email_taken' };
|
|
149
|
+
}
|
|
150
|
+
const id = randomUUID();
|
|
151
|
+
this.db.prepare(`INSERT INTO users
|
|
152
|
+
(id, email, password_hash, role, status, created_at, approved_at, last_login_at)
|
|
153
|
+
VALUES (?, ?, ?, 'user', 'approved', ?, ?, ?)`)
|
|
154
|
+
.run(id, email, passwordHash, now, now, now);
|
|
155
|
+
this.db.prepare('UPDATE invitations SET used_at = ?, used_by = ? WHERE id = ?').run(now, id, invite.id);
|
|
156
|
+
this.db.exec('COMMIT');
|
|
157
|
+
return { user: this.get(id) };
|
|
158
|
+
}
|
|
159
|
+
catch (err) {
|
|
160
|
+
this.db.exec('ROLLBACK');
|
|
161
|
+
throw err;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
addSession(tokenHash, userId, expiresAt) {
|
|
165
|
+
this.db.prepare('INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)')
|
|
166
|
+
.run(tokenHash, userId, Date.now(), expiresAt);
|
|
167
|
+
}
|
|
168
|
+
getSession(tokenHash) {
|
|
169
|
+
return this.db.prepare('SELECT * FROM sessions WHERE token_hash = ?').get(tokenHash) || null;
|
|
170
|
+
}
|
|
171
|
+
deleteSession(tokenHash) {
|
|
172
|
+
this.db.prepare('DELETE FROM sessions WHERE token_hash = ?').run(tokenHash);
|
|
173
|
+
}
|
|
174
|
+
deleteUserSessions(userId) {
|
|
175
|
+
this.db.prepare('DELETE FROM sessions WHERE user_id = ?').run(userId);
|
|
176
|
+
}
|
|
102
177
|
}
|
package/src/store/wire.js
CHANGED
|
@@ -1,55 +1,64 @@
|
|
|
1
1
|
/* Row <-> message shapes: what the model is sent (toWire) and what the UI shows (toView). */
|
|
2
|
-
|
|
3
2
|
function base(row) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
// content is set by each caller, after these, so the key order stays as it was.
|
|
4
|
+
const msg = { role: row.role };
|
|
5
|
+
if (row.tool_call_id)
|
|
6
|
+
msg.tool_call_id = row.tool_call_id;
|
|
7
|
+
if (row.tool_calls_json)
|
|
8
|
+
msg.tool_calls = JSON.parse(row.tool_calls_json);
|
|
9
|
+
if (row.reasoning_details_json)
|
|
10
|
+
msg.reasoning_details = JSON.parse(row.reasoning_details_json);
|
|
11
|
+
if (row.images_json)
|
|
12
|
+
msg.images = JSON.parse(row.images_json);
|
|
13
|
+
return msg;
|
|
10
14
|
}
|
|
11
|
-
|
|
12
15
|
/**
|
|
13
16
|
* What the model sees. A demoted tool message sends its stub; everything else
|
|
14
17
|
* sends its content verbatim. Reasoning text rides along; `buildMessages` in
|
|
15
18
|
* harness/provider.js decides whether the provider gets it back.
|
|
16
19
|
*/
|
|
17
20
|
export function toWire(row) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
21
|
+
const msg = base(row);
|
|
22
|
+
if (row.reasoning)
|
|
23
|
+
msg.reasoning = row.reasoning;
|
|
24
|
+
msg.content = row.role === 'tool' ? (row.stub_text ?? row.content) : (row.content ?? null);
|
|
25
|
+
// An epoch took these images off the wire. The note is a pure function of
|
|
26
|
+
// the row, so every rebuild sends the same bytes.
|
|
27
|
+
if (row.images_dropped && msg.images?.length) {
|
|
28
|
+
const n = msg.images.length;
|
|
29
|
+
msg.content = `${msg.content ?? ''}\n\n[${n} image${n === 1 ? '' : 's'} attached here ${n === 1 ? 'was' : 'were'} removed from context to save space. Ask the user to re-attach if you need to look again.]`;
|
|
30
|
+
delete msg.images;
|
|
31
|
+
}
|
|
32
|
+
// A model that was sent images needs the OpenAI multimodal shape -- an array
|
|
33
|
+
// of parts rather than a plain string. Built only when there are images, so
|
|
34
|
+
// every text-only turn keeps sending the plain string it always has, which
|
|
35
|
+
// is what most of the caching machinery in harness/provider.js assumes.
|
|
36
|
+
if (msg.images?.length) {
|
|
37
|
+
const parts = [];
|
|
38
|
+
// Still the row's own text here: parts are only built below.
|
|
39
|
+
if (msg.content)
|
|
40
|
+
parts.push({ type: 'text', text: msg.content });
|
|
41
|
+
for (const img of msg.images) {
|
|
42
|
+
parts.push({ type: 'image_url', image_url: { url: `data:${img.mime};base64,${img.data}` } });
|
|
43
|
+
}
|
|
44
|
+
msg.content = parts;
|
|
37
45
|
}
|
|
38
|
-
msg.
|
|
39
|
-
|
|
40
|
-
delete msg.images;
|
|
41
|
-
return msg;
|
|
46
|
+
delete msg.images;
|
|
47
|
+
return msg;
|
|
42
48
|
}
|
|
43
|
-
|
|
44
49
|
/** What the transcript shows: always the full text, stub or not. */
|
|
45
50
|
export function toView(row) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
const msg = base(row);
|
|
52
|
+
// The client needs a handle on each message to be able to rewind to one.
|
|
53
|
+
msg.seq = row.seq;
|
|
54
|
+
msg.content = row.content ?? null;
|
|
55
|
+
if (row.reasoning)
|
|
56
|
+
msg.reasoning = row.reasoning;
|
|
57
|
+
if (row.usage_json)
|
|
58
|
+
msg.usage = JSON.parse(row.usage_json);
|
|
59
|
+
if (row.stub_text)
|
|
60
|
+
msg.compacted = true;
|
|
61
|
+
if (row.origin_json)
|
|
62
|
+
msg.origin = JSON.parse(row.origin_json);
|
|
63
|
+
return msg;
|
|
55
64
|
}
|
package/src/tools/ask_tool.js
CHANGED
|
@@ -7,87 +7,87 @@
|
|
|
7
7
|
* only validates the call and turns the outcome into the tool result. A run
|
|
8
8
|
* with nobody to ask (an automation) never waits; it gets the fallback at once.
|
|
9
9
|
*/
|
|
10
|
-
|
|
11
10
|
export const ASK_USER = 'ask_user';
|
|
12
|
-
|
|
13
11
|
const MAX_QUESTION = 2000;
|
|
14
12
|
const MAX_CHOICES = 10;
|
|
15
13
|
const MAX_CHOICE = 200;
|
|
16
|
-
|
|
17
14
|
export function askToolDef() {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
15
|
+
return {
|
|
16
|
+
type: 'function',
|
|
17
|
+
function: {
|
|
18
|
+
name: ASK_USER,
|
|
19
|
+
description: [
|
|
20
|
+
'Ask the user one question and wait for the answer, then continue this same task',
|
|
21
|
+
'with it. The run pauses until they answer; if they do not answer in time, you are',
|
|
22
|
+
'told so and continue on your own assumptions.',
|
|
23
|
+
'',
|
|
24
|
+
'Use it only for information that materially changes the result and that you cannot',
|
|
25
|
+
'infer or safely assume: choosing between materially different files, accounts or',
|
|
26
|
+
'environments; a missing value; a decision with real downstream effects; a manual',
|
|
27
|
+
'step the user must do first.',
|
|
28
|
+
'',
|
|
29
|
+
'Do not use it to ask "should I continue?", for preferences with a sensible default,',
|
|
30
|
+
'to confirm ordinary reversible actions, or because the request is only mildly',
|
|
31
|
+
'ambiguous -- make a reasonable assumption and state it instead. One question per',
|
|
32
|
+
'call; offer `choices` when there are a few valid answers.'
|
|
33
|
+
].join('\n'),
|
|
34
|
+
parameters: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
question: { type: 'string', description: 'The question, written for the user to read.' },
|
|
38
|
+
choices: {
|
|
39
|
+
type: 'array',
|
|
40
|
+
items: { type: 'string' },
|
|
41
|
+
description: `Optional answers to pick from (up to ${MAX_CHOICES}).`
|
|
42
|
+
},
|
|
43
|
+
allow_free_text: {
|
|
44
|
+
type: 'boolean',
|
|
45
|
+
description: 'Whether the user may type their own answer instead of picking a choice. Default true.'
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
required: ['question']
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
55
52
|
}
|
|
56
|
-
|
|
57
53
|
/** The result text for each way a question can end. JSON, so it parses and reads. */
|
|
58
54
|
export function askResult(outcome) {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
55
|
+
if (outcome.answered)
|
|
56
|
+
return JSON.stringify({ answered: true, answer: outcome.answer });
|
|
57
|
+
const message = {
|
|
58
|
+
timeout: 'The user did not respond within the configured timeout.',
|
|
59
|
+
skipped: 'The user chose to let you continue without answering.',
|
|
60
|
+
unattended: 'No interactive user is available for this run.'
|
|
61
|
+
}[outcome.reason] || 'The question could not be put to the user.';
|
|
62
|
+
return JSON.stringify({
|
|
63
|
+
answered: false,
|
|
64
|
+
reason: outcome.reason,
|
|
65
|
+
message: `${message} Continue the task using your own reasonable assumptions, and state any`
|
|
66
|
+
+ ' important assumptions in your final answer when they materially affect the result.'
|
|
67
|
+
});
|
|
71
68
|
}
|
|
72
|
-
|
|
73
69
|
/**
|
|
74
70
|
* The tool handler. `ctx.askUser({ question, choices, allowFreeText })`
|
|
75
71
|
* resolves to `{ answered: true, answer }` or `{ answered: false, reason }`;
|
|
76
72
|
* without one (an unattended run) the fallback comes back at once.
|
|
77
73
|
*/
|
|
78
74
|
export async function callAskUser(args, ctx) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
75
|
+
const question = typeof args.question === 'string' ? args.question.trim() : '';
|
|
76
|
+
if (!question)
|
|
77
|
+
return 'Error: "question" is required and must be a non-empty string.';
|
|
78
|
+
if (question.length > MAX_QUESTION)
|
|
79
|
+
return `Error: "question" is over ${MAX_QUESTION} characters; ask something shorter.`;
|
|
80
|
+
if (args.choices !== undefined && !(Array.isArray(args.choices) && args.choices.every((c) => typeof c === 'string'))) {
|
|
81
|
+
return 'Error: "choices" must be a list of strings.';
|
|
82
|
+
}
|
|
83
|
+
const choices = [...new Set((args.choices || []).map((c) => c.trim()).filter(Boolean))];
|
|
84
|
+
if (choices.length > MAX_CHOICES)
|
|
85
|
+
return `Error: at most ${MAX_CHOICES} choices.`;
|
|
86
|
+
if (choices.some((c) => c.length > MAX_CHOICE))
|
|
87
|
+
return `Error: each choice must be under ${MAX_CHOICE} characters.`;
|
|
88
|
+
// Nothing to pick and nothing to type would be a question nobody can answer.
|
|
89
|
+
const allowFreeText = args.allow_free_text !== false || choices.length === 0;
|
|
90
|
+
if (ctx.unattended || !ctx.askUser)
|
|
91
|
+
return askResult({ answered: false, reason: 'unattended' });
|
|
92
|
+
return askResult(await ctx.askUser({ question, choices, allowFreeText }));
|
|
93
93
|
}
|