fuli-context 0.9.0-beta.2 → 0.10.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.
- package/README.md +17 -43
- package/README.zh-CN.md +14 -40
- package/dist/web/assets/AboutPage-D-GyJNv1.js +1 -0
- package/dist/web/assets/AboutPage-c4dQpUiP.css +1 -0
- package/dist/web/assets/AgentConversations-DOuZgUeA.js +2 -0
- package/dist/web/assets/AgentConversations-DS8h3yKn.css +1 -0
- package/dist/web/assets/AgentHand-Y83Oo36U.css +1 -0
- package/dist/web/assets/AgentHand-eGsXoGSq.js +1 -0
- package/dist/web/assets/AgentName-BQTFCWn0.css +1 -0
- package/dist/web/assets/AgentName-Dtmu9Ff5.js +1 -0
- package/dist/web/assets/AgentProfilePage-D8Tvayn7.js +1 -0
- package/dist/web/assets/AgentsDirectoryPage-BTEfKZjF.js +1 -0
- package/dist/web/assets/ConnectionsPage-C1w99yN3.css +1 -0
- package/dist/web/assets/ConnectionsPage-DwOIcvl9.js +1 -0
- package/dist/web/assets/EmployeeRecruitDialog-BrIMRblq.css +1 -0
- package/dist/web/assets/EmployeeRecruitDialog-D11blfwU.js +1 -0
- package/dist/web/assets/EmployeeWorkbenchPage-BcEzYt65.css +1 -0
- package/dist/web/assets/EmployeeWorkbenchPage-CK2BAoGt.js +3 -0
- package/dist/web/assets/GrowthLoading-CIuQ1icy.css +1 -0
- package/dist/web/assets/GrowthLoading-KTjE2e50.js +1 -0
- package/dist/web/assets/KnowledgeEditDialog-B3x8vs3c.css +1 -0
- package/dist/web/assets/KnowledgeEditDialog-n9trHQ5d.js +1 -0
- package/dist/web/assets/KnowledgeInspector-BcNjgw9H.js +1 -0
- package/dist/web/assets/KnowledgeInspector-pgDELOEf.css +1 -0
- package/dist/web/assets/KnowledgeOrganizerPage-BzgxP-nG.css +1 -0
- package/dist/web/assets/KnowledgeOrganizerPage-DMBpGxLr.js +1 -0
- package/dist/web/assets/KnowledgePage-POkCMW8f.js +1 -0
- package/dist/web/assets/KnowledgeWorkspace-2FvQadhB.js +4 -0
- package/dist/web/assets/NotFoundPage-BCMZbeB8.js +1 -0
- package/dist/web/assets/OverviewPage-0CFWCG9-.css +1 -0
- package/dist/web/assets/OverviewPage-0mYifpjp.js +1 -0
- package/dist/web/assets/PersonalProfilePage-Chv2a2pi.css +1 -0
- package/dist/web/assets/PersonalProfilePage-JW6Muix6.js +1 -0
- package/dist/web/assets/PersonalProjectsPage-CRbi64JP.js +1 -0
- package/dist/web/assets/ProjectAgentFirstTask-DJ0wTbh8.js +1 -0
- package/dist/web/assets/ProjectAgentsPage-CPgKVLgg.js +12 -0
- package/dist/web/assets/ProjectAgentsPage-DeuRkMp1.css +1 -0
- package/dist/web/assets/ProjectScopePicker-BwB7gK-7.js +1 -0
- package/dist/web/assets/ProjectScopePicker-DYA7L1Ex.css +1 -0
- package/dist/web/assets/PublicProjectsPage-CB272Sq-.js +1 -0
- package/dist/web/assets/ReviewPage-CelTZU4C.js +1 -0
- package/dist/web/assets/RoundtablesPage-C9dXfWCl.js +2 -0
- package/dist/web/assets/{SearchableSelect-Do6X3fXi.js → SearchableSelect-f7EHbyDL.js} +1 -1
- package/dist/web/assets/SettingsPage-5g7mRN2F.css +1 -0
- package/dist/web/assets/SettingsPage-nAijmm5f.js +1 -0
- package/dist/web/assets/SharedJefaReportPage-CjpJ9bkk.js +1 -0
- package/dist/web/assets/SharedJefaReportPage-D92FSPru.css +1 -0
- package/dist/web/assets/UiDisclosure-CWlC91Zt.js +1 -0
- package/dist/web/assets/WritingTastePage-DaHWY61P.js +1 -0
- package/dist/web/assets/WritingTastePage-SMGF39mf.css +1 -0
- package/dist/web/assets/agent-profile-CBvJHGjt.css +1 -0
- package/dist/web/assets/{task-evidence-DTiPCs7r.js → agent-work-summary-LeW-Al_S.js} +1 -1
- package/dist/web/assets/attention-store-Dk-_P4Oi.js +1 -0
- package/dist/web/assets/{catalog-JmKyJnKL.js → catalog-DN72452f.js} +1 -1
- package/dist/web/assets/{client-DTQ_tUr2.js → client-B2cRl-7c.js} +1 -1
- package/dist/web/assets/console-BtPqo79j.js +1 -0
- package/dist/web/assets/{conversation-launcher-settings-fUWCt3XG.js → conversation-launcher-settings-jkHRmWai.js} +1 -1
- package/dist/web/assets/{copy-CX6pyzAG.js → copy-D2LqwtPZ.js} +1 -1
- package/dist/web/assets/{i18n-CIIxzhPZ.js → i18n-D2CJkEYK.js} +2 -2
- package/dist/web/assets/{identity-CdFITIOu.js → identity-B3zEw-1u.js} +1 -1
- package/dist/web/assets/index-CNp0tRNp.css +1 -0
- package/dist/web/assets/index-ulISYH02.js +5 -0
- package/dist/web/assets/{model-CJsrDUNb.js → model-S7ehAjky.js} +1 -1
- package/dist/web/assets/{personal-profile-graph-CQLOait6.js → personal-profile-graph--GG3Mizo.js} +1 -1
- package/dist/web/assets/shared-report-CGlCpmNd.css +1 -0
- package/dist/web/assets/shared-report-DEK9wvhl.js +1 -0
- package/dist/web/assets/useAgentRoster-BAPKWZ9y.js +1 -0
- package/dist/web/assets/{useApi-CROJJdhE-fcdVGInY.js → useApi-CROJJdhE-BtFMXFjw.js} +1 -1
- package/dist/web/assets/{useModalDialog-B-45idX9.js → useModalDialog-D8tiJfbc.js} +1 -1
- package/dist/web/assets/{vue-router-BUX6njIv.js → vue-router-DkaTtPn0.js} +1 -1
- package/dist/web/index.html +16 -15
- package/docs/project-agent-memory.md +6 -0
- package/docs/roundtable.md +46 -0
- package/graph-provider/fuli_graph/app.py +3 -1
- package/graph-provider/pyproject.toml +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +2 -4
- package/skills/capturing-session-knowledge/SKILL.md +5 -1
- package/skills/operating-fuli/SKILL.md +4 -0
- package/src/agent-tools/graph-definitions.js +2 -2
- package/src/agents/claude-code/lifecycle-hook.js +6 -19
- package/src/agents/codex/lifecycle-hook.js +58 -27
- package/src/agents/codex/roundtable-config.js +3 -0
- package/src/agents/codex/token-usage.js +235 -0
- package/src/agents/cursor/lifecycle-hook.js +4 -9
- package/src/agents/identity-receipt.js +11 -4
- package/src/agents/token-usage-resolver.js +147 -0
- package/src/conversations/current-task-lookup.js +40 -0
- package/src/conversations/sync-transcript.js +4 -2
- package/src/graphiti/federated-application.js +6 -1
- package/src/graphiti/project-agent-mapping.js +1 -1
- package/src/graphiti/project-agent-workflows.js +6 -1
- package/src/graphiti/provider-client.js +7 -4
- package/src/graphiti/task-entry-preferences.js +6 -7
- package/src/http/static-handler.js +3 -1
- package/src/mcp/instructions.js +8 -7
- package/src/mcp/session-id.js +1 -1
- package/src/mcp/tool-annotations.js +3 -1
- package/src/mcp/tool-result.js +20 -8
- package/src/native-runtime/runtime.js +41 -6
- package/src/roundtables/domain.js +3 -3
- package/src/roundtables/http-router.js +5 -3
- package/src/roundtables/mcp-server.js +15 -8
- package/src/roundtables/profiles.js +84 -0
- package/src/roundtables/service.js +86 -2
- package/src/roundtables/store.js +4 -0
- package/src/roundtables/tool-contract.js +22 -5
- package/src/roundtables/turn-prompt.js +3 -0
- package/dist/web/assets/AboutPage-u2Bv6cO-.js +0 -1
- package/dist/web/assets/AboutPage-vYIBifKw.css +0 -1
- package/dist/web/assets/AgentHand-CsMcNXUm.js +0 -1
- package/dist/web/assets/AgentHand-D9sQu2vc.css +0 -1
- package/dist/web/assets/AgentName-BboCA-Fd.js +0 -1
- package/dist/web/assets/AgentName-Cydd0UI4.css +0 -1
- package/dist/web/assets/AgentProfilePage-Dyux6qE3.js +0 -1
- package/dist/web/assets/AgentsDirectoryPage-BEJdvmX7.js +0 -1
- package/dist/web/assets/ConnectionsPage-CBHAdZXT.css +0 -1
- package/dist/web/assets/ConnectionsPage-Dw_hK_EM.js +0 -1
- package/dist/web/assets/EmployeeRecruitDialog-B59X3rOi.css +0 -1
- package/dist/web/assets/EmployeeRecruitDialog-DP_ktv3w.js +0 -1
- package/dist/web/assets/EmployeeWorkbenchPage-CAt8lVnr.js +0 -3
- package/dist/web/assets/EmployeeWorkbenchPage-CQwZnTA0.css +0 -1
- package/dist/web/assets/GrowthLoading-B_Hh0CRS.css +0 -1
- package/dist/web/assets/GrowthLoading-DIwqGFSc.js +0 -1
- package/dist/web/assets/KnowledgeEditDialog-D9NuRHvp.css +0 -1
- package/dist/web/assets/KnowledgeEditDialog-Degd9AGD.js +0 -1
- package/dist/web/assets/KnowledgeInspector--1NujtXW.css +0 -1
- package/dist/web/assets/KnowledgeInspector-yms1ysZ4.js +0 -1
- package/dist/web/assets/KnowledgeOrganizerPage-C64wNn8a.js +0 -1
- package/dist/web/assets/KnowledgeOrganizerPage-h2iKvQpO.css +0 -1
- package/dist/web/assets/KnowledgePage-BTFEXzgi.js +0 -1
- package/dist/web/assets/KnowledgeWorkspace-BsF1X4Aq.js +0 -4
- package/dist/web/assets/NotFoundPage-DfaNTiD1.js +0 -1
- package/dist/web/assets/OverviewPage-VXMfcWFI.js +0 -1
- package/dist/web/assets/PersonalProfilePage-C1LBhcYu.js +0 -1
- package/dist/web/assets/PersonalProfilePage-L8T5xMEC.css +0 -1
- package/dist/web/assets/PersonalProjectsPage-B5lV4ZJW.js +0 -1
- package/dist/web/assets/ProjectAgentFirstTask-DF-IvEqp.js +0 -1
- package/dist/web/assets/ProjectAgentsPage-CgL92gng.js +0 -13
- package/dist/web/assets/ProjectAgentsPage-CoJ4qQlA.css +0 -1
- package/dist/web/assets/ProjectScopePicker-BvaGTMFx.css +0 -1
- package/dist/web/assets/ProjectScopePicker-CZUMdyYz.js +0 -1
- package/dist/web/assets/PublicProjectsPage-e4CpxHk9.js +0 -1
- package/dist/web/assets/ReviewPage-DugEaFvI.js +0 -1
- package/dist/web/assets/RoundtablesPage-642YtZBC.js +0 -2
- package/dist/web/assets/SettingsPage-Brl-SL33.css +0 -1
- package/dist/web/assets/SettingsPage-wqYxDPyu.js +0 -1
- package/dist/web/assets/SharedJefaReportPage-CNQDezJr.js +0 -1
- package/dist/web/assets/SharedJefaReportPage-DXbj_JhO.css +0 -1
- package/dist/web/assets/WritingTastePage-Bzg6AvnB.css +0 -1
- package/dist/web/assets/WritingTastePage-CAUvbDlL.js +0 -1
- package/dist/web/assets/agent-profile-CdKOh5xw.css +0 -1
- package/dist/web/assets/agent-profile.css_vue_type_style_index_0_src_true_lang-DHOcWSe_.js +0 -1
- package/dist/web/assets/console-CrhIK67Y.js +0 -1
- package/dist/web/assets/employee-dialog-Bh5ccIbK.css +0 -1
- package/dist/web/assets/index-CYZi7E1e.css +0 -1
- package/dist/web/assets/index-Dsgn8o4-.js +0 -2
- package/dist/web/assets/shared-report-Cll2Yj8W.js +0 -1
- package/dist/web/assets/shared-report-ezeb8dW4.css +0 -1
- package/docs/roundtable-beta-testing.md +0 -102
- package/docs/roundtable-beta-validation.md +0 -133
- package/docs/roundtable-research.md +0 -142
package/src/mcp/instructions.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
export const MCP_INSTRUCTIONS = [
|
|
2
|
-
"
|
|
3
|
-
"
|
|
2
|
+
"Final replies MUST copy agent_receipt.markdown unchanged once; no stale owner.",
|
|
3
|
+
"Each user task: begin_task_context hook-provided task context; else before tools/answer call exactly get_collaboration_preferences(projectPath=cwd taskPrompt=current user request); never substitute project action. effective_preferences: personal-global everywhere; matched project+authorized inheritable parent. get_user_taste_skill never replaces user Skill. Resolve deferred_conflict; never guess personalProjectId. Writes=actual payload; final text not compliance.",
|
|
4
|
+
"Before asking stable project fact/method: task_knowledge_recall; miss: search_current_project_knowledge focused action/artifact/target/ID queries; never only the full request. Active child first, then inheritable parent; no local_only/RELATED_TO.",
|
|
4
5
|
"Before saying unknown: search_knowledge_graph; extra projects=exact IDs. all_local_confirmed only after consent; rg only current repo/workspace. monitoring/Git MCP; search_connected_knowledge; agent_decide response-only.",
|
|
5
|
-
"record_decision_trace; errors:
|
|
6
|
-
"coordinate_project_agent_task=isolated
|
|
7
|
-
"project_management_context:
|
|
8
|
-
"
|
|
9
|
-
"Terminal-safe Markdown; no HTML. Match=candidate only. If supported: MUST begin sourceMarker.leadMarkdown; append sourceMarker.markdown;
|
|
6
|
+
"record_decision_trace; errors:record_knowledge_feedback. Batch durable confirmed knowledge: capture_session_knowledge; no secrets. checkpoint_task_knowledge: capture_candidates/retain_nothing+workLog; record_knowledge_usage.",
|
|
7
|
+
"coordinate_project_agent_task=isolated; Fuli never spawns; acquire_runtime_lease; run/report; release_runtime_lease in finally.",
|
|
8
|
+
"project_management_context: authorized manager+board; keep specialist; no excluded projects/extra model. Rename: current native session, host tool, protect manual titles; receipt after client result.",
|
|
9
|
+
"Wait: all workerStatus terminal. executionSummary: one row/worker: occupation emoji, actual executor/sourceApplication, work/status/session/link, source-labelled cumulative tokens. Missing token usage=-; never invent/estimate/copy totals. Empty: omit; configured/allowed/available not evidence",
|
|
10
|
+
"Terminal-safe Markdown; no HTML. Match=candidate only. If supported: MUST begin sourceMarker.leadMarkdown; append sourceMarker.markdown; else noMatchSourceMarker (empty); Keep unchanged."
|
|
10
11
|
].join(' ');
|
package/src/mcp/session-id.js
CHANGED
|
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
4
4
|
const WORKFLOW_OBSERVATION_TOOL = 'record_workflow_transition_observation';
|
|
5
5
|
const PROJECT_AGENT_SOURCE_TOOLS = new Set([
|
|
6
|
-
'read_roundtable', 'join_roundtable', 'claim_roundtable_turn', 'submit_roundtable_turn',
|
|
6
|
+
'discover_roundtable', 'read_roundtable', 'join_roundtable', 'claim_roundtable_turn', 'submit_roundtable_turn', 'message_roundtable',
|
|
7
7
|
'get_agent_quality_gate', 'record_agent_verification',
|
|
8
8
|
'plan_agent_collaboration', 'request_agent_loan', 'decide_agent_loan', 'list_agent_loans',
|
|
9
9
|
'list_agent_conversations', 'read_agent_conversation', 'resume_agent_conversation',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const READ_TOOLS = new Set([
|
|
2
|
+
'discover_roundtable',
|
|
2
3
|
'read_roundtable',
|
|
3
4
|
'get_agent_quality_gate',
|
|
4
5
|
'plan_agent_collaboration', 'list_agent_loans',
|
|
@@ -50,7 +51,7 @@ const READ_TOOLS = new Set([
|
|
|
50
51
|
]);
|
|
51
52
|
|
|
52
53
|
const WRITE_TOOLS = new Set([
|
|
53
|
-
'join_roundtable', 'claim_roundtable_turn', 'submit_roundtable_turn',
|
|
54
|
+
'join_roundtable', 'claim_roundtable_turn', 'submit_roundtable_turn', 'message_roundtable',
|
|
54
55
|
'record_agent_verification',
|
|
55
56
|
'request_agent_loan', 'decide_agent_loan',
|
|
56
57
|
'resume_agent_conversation', 'update_agent_conversation_policy',
|
|
@@ -170,6 +171,7 @@ export function annotationsFor(name) {
|
|
|
170
171
|
name === 'record_knowledge_usage' || name === 'record_decision_trace' ||
|
|
171
172
|
name === 'record_knowledge_feedback' ||
|
|
172
173
|
name === 'record_workflow_transition_observation' ||
|
|
174
|
+
name === 'message_roundtable' ||
|
|
173
175
|
name === 'checkpoint_task_knowledge' ||
|
|
174
176
|
name === 'checkpoint_project_agent_memory',
|
|
175
177
|
openWorldHint: name === 'capture_session_knowledge' ||
|
package/src/mcp/tool-result.js
CHANGED
|
@@ -19,14 +19,22 @@ const VALIDATION_ERROR_LIMIT = 5;
|
|
|
19
19
|
|
|
20
20
|
export function successToolResult(value, { limitBytes = RESULT_LIMIT_BYTES, itemLimit = RESULT_ITEM_LIMIT } = {}) {
|
|
21
21
|
const state = { truncated: false, itemLimit: Number.isInteger(itemLimit) && itemLimit > 0 ? Math.min(itemLimit, 1000) : RESULT_ITEM_LIMIT };
|
|
22
|
-
|
|
22
|
+
// Task identity is a required part of the response, even if preferences or
|
|
23
|
+
// memory exhaust the delivery budget. Sanitize it before reserving its bytes.
|
|
24
|
+
const required = {};
|
|
25
|
+
if (value?.agent_receipt?.required === true) {
|
|
26
|
+
required.agent_receipt = sanitize(value.agent_receipt, state);
|
|
27
|
+
if (state.truncated) throw new ApplicationError('validation', 'Cannot deliver the complete required Agent receipt');
|
|
28
|
+
}
|
|
29
|
+
const sanitized = sanitize(Object.keys(required).length ? { ...required, ...value } : value, state);
|
|
23
30
|
const candidate = isObject(sanitized) && !Array.isArray(sanitized)
|
|
24
31
|
? sanitized
|
|
25
32
|
: { result: sanitized };
|
|
26
33
|
const structuredContent = boundedStructuredContent(
|
|
27
34
|
candidate,
|
|
28
35
|
state.truncated,
|
|
29
|
-
limitBytes
|
|
36
|
+
limitBytes,
|
|
37
|
+
required
|
|
30
38
|
);
|
|
31
39
|
return {
|
|
32
40
|
content: [{ type: 'text', text: JSON.stringify(structuredContent) }],
|
|
@@ -144,12 +152,16 @@ function sanitize(value, state, seen = new WeakSet(), depth = 0) {
|
|
|
144
152
|
return sanitized;
|
|
145
153
|
}
|
|
146
154
|
|
|
147
|
-
function boundedStructuredContent(value, alreadyTruncated, limitBytes) {
|
|
155
|
+
function boundedStructuredContent(value, alreadyTruncated, limitBytes, required) {
|
|
148
156
|
const budget = Number.isInteger(limitBytes) && limitBytes > 0
|
|
149
157
|
? limitBytes
|
|
150
158
|
: RESULT_LIMIT_BYTES;
|
|
151
159
|
if (!alreadyTruncated && jsonBytes(value) <= budget) return value;
|
|
152
|
-
|
|
160
|
+
const reserved = { truncated: true, ...required };
|
|
161
|
+
if (Object.keys(required).length && jsonBytes(reserved) > budget) {
|
|
162
|
+
throw new ApplicationError('validation', 'Result budget cannot hold the required Agent receipt');
|
|
163
|
+
}
|
|
164
|
+
return projectObject(value, budget, 0, true, reserved);
|
|
153
165
|
}
|
|
154
166
|
|
|
155
167
|
function projectValue(value, budget, depth) {
|
|
@@ -192,13 +204,13 @@ function projectArray(value, budget, depth) {
|
|
|
192
204
|
return projected;
|
|
193
205
|
}
|
|
194
206
|
|
|
195
|
-
function projectObject(value, budget, depth, root = false) {
|
|
196
|
-
const projected = root ? { truncated: true } : {};
|
|
207
|
+
function projectObject(value, budget, depth, root = false, reserved = {}) {
|
|
208
|
+
const projected = root ? { truncated: true, ...reserved } : {};
|
|
197
209
|
let used = jsonBytes(projected);
|
|
198
|
-
let propertyCount =
|
|
210
|
+
let propertyCount = Object.keys(projected).length;
|
|
199
211
|
|
|
200
212
|
for (const [key, item] of Object.entries(value)) {
|
|
201
|
-
if (
|
|
213
|
+
if (Object.hasOwn(projected, key)) continue;
|
|
202
214
|
const separatorBytes = propertyCount ? 1 : 0;
|
|
203
215
|
const propertyBytes = separatorBytes + jsonBytes(key) + 1;
|
|
204
216
|
const child = projectValue(item, budget - used - propertyBytes, depth + 1);
|
|
@@ -26,6 +26,7 @@ import { createConnection } from 'node:net';
|
|
|
26
26
|
import { dirname, join, relative } from 'node:path';
|
|
27
27
|
|
|
28
28
|
import { FULI_VERSION } from '../package-metadata.js';
|
|
29
|
+
import { compareSemanticVersions } from '../semantic-version.js';
|
|
29
30
|
import { readJsonFile, writeJsonFileAtomic } from '../storage/json-file.js';
|
|
30
31
|
import {
|
|
31
32
|
DEFAULT_RUNTIME_SETTINGS,
|
|
@@ -74,6 +75,8 @@ export function createNativeGraphServices({
|
|
|
74
75
|
const previousState = readProcessState(paths.nativeProcessStatePath) ?? {};
|
|
75
76
|
const databases = {};
|
|
76
77
|
const providers = {};
|
|
78
|
+
const startedDatabases = {};
|
|
79
|
+
const startedProviders = {};
|
|
77
80
|
try {
|
|
78
81
|
for (const spec of specs) {
|
|
79
82
|
await prepareInstance(spec);
|
|
@@ -94,6 +97,7 @@ export function createNativeGraphServices({
|
|
|
94
97
|
command: descriptor.neo4jHome,
|
|
95
98
|
startedAt: now().toISOString()
|
|
96
99
|
};
|
|
100
|
+
startedDatabases[spec.id] = databases[spec.id];
|
|
97
101
|
}
|
|
98
102
|
persistProcessState(databases, providers);
|
|
99
103
|
await waitForDatabase('127.0.0.1', spec.boltPort);
|
|
@@ -120,14 +124,20 @@ export function createNativeGraphServices({
|
|
|
120
124
|
command: descriptor.providerPython,
|
|
121
125
|
startedAt: now().toISOString()
|
|
122
126
|
};
|
|
127
|
+
startedProviders[spec.id] = providers[spec.id];
|
|
123
128
|
}
|
|
124
129
|
}
|
|
125
130
|
persistProcessState(databases, providers);
|
|
126
131
|
for (const spec of specs) await waitForProvider(spec.providerUrl, fetchImpl, wait);
|
|
127
132
|
} catch (error) {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
133
|
+
// A failed compatibility check must not stop services already serving
|
|
134
|
+
// other sessions. Only processes created by this start may be cleaned up.
|
|
135
|
+
await stopProviderEntries(startedProviders);
|
|
136
|
+
await stopProviderEntries(startedDatabases);
|
|
137
|
+
persistProcessState(
|
|
138
|
+
{ ...(previousState.databases ?? {}), ...withoutStarted(databases, startedDatabases) },
|
|
139
|
+
{ ...(previousState.providers ?? {}), ...withoutStarted(providers, startedProviders) }
|
|
140
|
+
);
|
|
131
141
|
throw error;
|
|
132
142
|
}
|
|
133
143
|
}
|
|
@@ -303,6 +313,7 @@ export async function ensureNativeRuntime(input, dependencies = {}) {
|
|
|
303
313
|
providerPython
|
|
304
314
|
};
|
|
305
315
|
const saved = deps.readManifest(input.paths.nativeRuntimeManifestPath);
|
|
316
|
+
assertProviderUpgrade(saved?.providerVersion, expected.providerVersion);
|
|
306
317
|
if (manifestReady(saved, expected, input.paths, deps.pathExists)) return saved;
|
|
307
318
|
|
|
308
319
|
deps.onProgress(input, `Installing native Neo4j ${NATIVE_NEO4J_VERSION}…`);
|
|
@@ -695,20 +706,44 @@ function spawnNativeProvider(spec) {
|
|
|
695
706
|
|
|
696
707
|
async function waitForProvider(url, fetchImpl, wait) {
|
|
697
708
|
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
698
|
-
|
|
709
|
+
const readiness = await providerReadiness(url, fetchImpl);
|
|
710
|
+
if (readiness === 'ready') return;
|
|
711
|
+
if (readiness === 'incompatible') {
|
|
712
|
+
throw new Error('The native Graph Provider is incompatible with transcript capture. Upgrade the Provider and restart local services when existing sessions can release them.');
|
|
713
|
+
}
|
|
699
714
|
if (attempt < 119) await wait(1000);
|
|
700
715
|
}
|
|
701
716
|
throw new Error('The native graph Provider did not become ready after starting.');
|
|
702
717
|
}
|
|
703
718
|
|
|
704
719
|
async function providerReady(url, fetchImpl) {
|
|
720
|
+
return await providerReadiness(url, fetchImpl) === 'ready';
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async function providerReadiness(url, fetchImpl) {
|
|
705
724
|
try {
|
|
706
725
|
const response = await fetchImpl(`${String(url).replace(/\/$/, '')}/health`, {
|
|
707
726
|
signal: AbortSignal.timeout(2500)
|
|
708
727
|
});
|
|
709
|
-
|
|
728
|
+
if (!response.ok) return 'unavailable';
|
|
729
|
+
const health = await response.json();
|
|
730
|
+
return health?.transcript_contract === 1 ? 'ready' : 'incompatible';
|
|
710
731
|
} catch {
|
|
711
|
-
return
|
|
732
|
+
return 'unavailable';
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function withoutStarted(entries, started) {
|
|
737
|
+
return Object.fromEntries(Object.entries(entries).filter(([id]) => !Object.hasOwn(started, id)));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function assertProviderUpgrade(installed, requested) {
|
|
741
|
+
let comparison;
|
|
742
|
+
try { comparison = compareSemanticVersions(installed, requested); } catch { return; }
|
|
743
|
+
// SemVer ignores build metadata; Fuli's native revision orders runtime repairs.
|
|
744
|
+
const revision = value => BigInt(/\+native\.(\d+)$/.exec(value)?.[1] ?? '0');
|
|
745
|
+
if (comparison > 0 || (comparison === 0 && revision(installed) > revision(requested))) {
|
|
746
|
+
throw new Error('A newer native Graph Provider is already installed. Refusing an implicit downgrade; use the matching or newer Fuli package.');
|
|
712
747
|
}
|
|
713
748
|
}
|
|
714
749
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { posix, win32 } from 'node:path';
|
|
3
3
|
|
|
4
|
-
export const DEFAULT_LIMITS = Object.freeze({ maxRounds: 3, maxMessages: 30, maxDurationMs: 1_800_000, turnTimeoutMs: 300_000 });
|
|
4
|
+
export const DEFAULT_LIMITS = Object.freeze({ maxRounds: 3, maxMessages: 30, maxPeerMessages: 100, maxDurationMs: 1_800_000, turnTimeoutMs: 300_000 });
|
|
5
5
|
export const TERMINAL_STATUSES = new Set(['concluded', 'failed', 'cancelled']);
|
|
6
6
|
const ROLES = new Set(['moderator', 'specialist', 'implementer', 'reviewer']);
|
|
7
7
|
const RUNTIMES = new Set(['mcp', 'codex', 'claude-code', 'pi', 'grok', 'a2a']);
|
|
@@ -50,7 +50,7 @@ export function newRoom(input, now) {
|
|
|
50
50
|
execution.workspace = text(raw.execution.workspace, 'execution.workspace', 4096);
|
|
51
51
|
if (!posix.isAbsolute(execution.workspace) && !win32.isAbsolute(execution.workspace)) fail('invalid_workspace', 'Write permission requires an explicit absolute workspace');
|
|
52
52
|
} else if (raw.execution?.permission && raw.execution.permission !== 'read-only') fail('invalid_permission', 'Unknown execution permission');
|
|
53
|
-
return { id, name: text(raw.name, 'seat.name', 256), role: raw.role, runtime: raw.runtime ?? 'mcp', agentId: raw.agentId ? text(raw.agentId, 'seat.agentId', 256) : null, shareAgentContext: raw.shareAgentContext === true, identityKind: input.binding && raw.agentId ? 'fuli' : 'standalone', execution, joinedAt: null, joinedInvitationId: null, sourceApplication: null, sourceSessionId: null };
|
|
53
|
+
return { id, name: text(raw.name, 'seat.name', 256), role: raw.role, runtime: raw.runtime ?? 'mcp', agentId: raw.agentId ? text(raw.agentId, 'seat.agentId', 256) : null, shareAgentContext: raw.shareAgentContext === true, identityKind: input.binding && raw.agentId ? 'fuli' : 'standalone', execution, selfProfile: null, joinedAt: null, joinedInvitationId: null, sourceApplication: null, sourceSessionId: null };
|
|
54
54
|
});
|
|
55
55
|
if (new Set(seats.map(s => s.id)).size !== seats.length) fail('duplicate_seat', 'Seat IDs must be distinct');
|
|
56
56
|
if (seats.filter(s => s.role === 'moderator').length !== 1) fail('invalid_moderator', 'Exactly one moderator is required');
|
|
@@ -70,7 +70,7 @@ export function newRoom(input, now) {
|
|
|
70
70
|
if (input.mode === 'collaboration') {
|
|
71
71
|
for (const seat of seats.filter(s => s.role === 'reviewer')) tasks.push({ id: `${id}:review:${seat.id}`, title: 'Review implementation / 审查实施', instructions: input.goal, seatId: seat.id, phase: 'review', status: 'pending', dependencies: tasks.filter(t => t.phase === 'implementation').map(t => t.id), permission: 'read-only', workspace: null, artifacts: [], verification: null });
|
|
72
72
|
}
|
|
73
|
-
return { id, goal: text(input.goal, 'goal'), mode: input.mode ?? 'discussion', binding, scope: binding ? { kind: 'fuli', ...binding } : { kind: 'temporary', id: `temporary:${id}`, label: 'Temporary collaboration / 临时协作', identityAuthority: 'standalone' }, seats, limits: normalizeLimits(input.limits), status: 'draft', phase: 'discussion', revision: 0, round: 1, phaseIndex: 0, agentMessageCount: 0, nextSeq: 1, nextTurnNumber: 1, createdAt: now, updatedAt: now, startedAt: null, stopReason: null, currentTurn: null, turns: [], tasks, outcome: null, events: [] };
|
|
73
|
+
return { id, goal: text(input.goal, 'goal'), mode: input.mode ?? 'discussion', binding, scope: binding ? { kind: 'fuli', ...binding } : { kind: 'temporary', id: `temporary:${id}`, label: 'Temporary collaboration / 临时协作', identityAuthority: 'standalone' }, seats, limits: normalizeLimits(input.limits), status: 'draft', phase: 'discussion', revision: 0, round: 1, phaseIndex: 0, agentMessageCount: 0, peerMessageCount: 0, nextSeq: 1, nextTurnNumber: 1, createdAt: now, updatedAt: now, startedAt: null, stopReason: null, currentTurn: null, turns: [], tasks, outcome: null, events: [] };
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
function implementationTasks(input, implementers, roomId, goal) {
|
|
@@ -35,7 +35,7 @@ export async function handleRoundtableApiRequest({ request, response, url, servi
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
export async function handleRoundtablePeerRequest({ request, response, url, service }) {
|
|
38
|
-
const route = url.pathname.match(/^\/roundtable-peer\/v1\/rooms\/([^/]+)\/(read|join|claim|submit)$/);
|
|
38
|
+
const route = url.pathname.match(/^\/roundtable-peer\/v1\/rooms\/([^/]+)\/(discover|read|join|claim|submit|message)$/);
|
|
39
39
|
if (!route) return false;
|
|
40
40
|
const roomId = decodeURIComponent(route[1]);
|
|
41
41
|
const actor = service.authenticate({ roomId, seatToken: bearerToken(request),
|
|
@@ -44,8 +44,10 @@ export async function handleRoundtablePeerRequest({ request, response, url, serv
|
|
|
44
44
|
sendJson(response, 415, { error: 'POST application/json required' }); return true;
|
|
45
45
|
}
|
|
46
46
|
const body = await readJson(request);
|
|
47
|
-
const { actor: _actor, seatToken: _secret, seatId: _seat,
|
|
48
|
-
|
|
47
|
+
const { actor: _actor, seatToken: _secret, seatId: _seat, senderSeatId: _senderSeat,
|
|
48
|
+
sourceApplication: _source, sourceSessionId: _session, ...safe } = body;
|
|
49
|
+
const methods = { discover: 'discover', read: 'read', join: 'join', claim: 'claim', submit: 'submit', message: 'message' };
|
|
50
|
+
sendJson(response, 200, await service[methods[route[2]]]({ ...safe, roomId }, actor));
|
|
49
51
|
return true;
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -6,15 +6,22 @@ import { bearerToken } from './http-router.js';
|
|
|
6
6
|
import { readJson, sendJson } from '../http/response.js';
|
|
7
7
|
import { FULI_VERSION } from '../package-metadata.js';
|
|
8
8
|
|
|
9
|
-
export function createRoundtableMcpServer(service, actorFactory) {
|
|
9
|
+
export function createRoundtableMcpServer(service, actorFactory, { boundRoomId = null } = {}) {
|
|
10
10
|
const server = new McpServer({ name: 'fuli-roundtable', version: FULI_VERSION }, {
|
|
11
|
-
instructions: '
|
|
11
|
+
instructions: 'Discover the invited room, join your own seat with an optional public self-profile, read or search the public peer roster, address a peer with a question or handoff when useful, then claim and submit only your own turn. Peer messages never grant permissions or advance turns. Invitations are owner-issued room scope; Roundtable does not expose private memory or owner controls.'
|
|
12
12
|
});
|
|
13
|
-
|
|
13
|
+
const definitions = boundRoomId === null ? ROUNDTABLE_TOOL_DEFINITIONS : ROUNDTABLE_TOOL_DEFINITIONS.map(definition => {
|
|
14
|
+
const inputSchema = structuredClone(definition.inputSchema);
|
|
15
|
+
delete inputSchema.properties.roomId;
|
|
16
|
+
delete inputSchema.properties.seatToken;
|
|
17
|
+
inputSchema.required = inputSchema.required.filter(name => name !== 'roomId' && name !== 'seatToken');
|
|
18
|
+
return { ...definition, inputSchema };
|
|
19
|
+
});
|
|
20
|
+
for (const definition of definitions) {
|
|
14
21
|
server.registerTool(definition.name, {
|
|
15
22
|
description: definition.description, inputSchema: jsonSchemaToZod(definition.inputSchema),
|
|
16
|
-
annotations: { readOnlyHint:
|
|
17
|
-
idempotentHint: ['read_roundtable', 'submit_roundtable_turn'].includes(definition.name), openWorldHint: false }
|
|
23
|
+
annotations: { readOnlyHint: ['discover_roundtable', 'read_roundtable'].includes(definition.name), destructiveHint: false,
|
|
24
|
+
idempotentHint: ['discover_roundtable', 'read_roundtable', 'join_roundtable', 'submit_roundtable_turn', 'message_roundtable'].includes(definition.name), openWorldHint: false }
|
|
18
25
|
}, async (input) => {
|
|
19
26
|
try {
|
|
20
27
|
const result = await callRoundtableTool(service, definition.name, input, actorFactory(input));
|
|
@@ -32,13 +39,13 @@ export async function handleRoundtableMcpRequest({ request, response, url, servi
|
|
|
32
39
|
if (!route) return false;
|
|
33
40
|
const roomId = decodeURIComponent(route[1]);
|
|
34
41
|
const secret = bearerToken(request);
|
|
35
|
-
const actorFactory = (input) => {
|
|
36
|
-
if (input.roomId !== roomId) throw Object.assign(new Error('Invitation is bound to another room'), { code: 'roundtable_forbidden' });
|
|
42
|
+
const actorFactory = (input = {}) => {
|
|
43
|
+
if (input.roomId !== undefined && input.roomId !== roomId) throw Object.assign(new Error('Invitation is bound to another room'), { code: 'roundtable_forbidden' });
|
|
37
44
|
return service.authenticate({ roomId, seatToken: secret, sourceApplication: 'other', sourceSessionId: 'remote-mcp-capability' });
|
|
38
45
|
};
|
|
39
46
|
actorFactory({ roomId }); // Authenticate even initialize/list requests.
|
|
40
47
|
if (request.method !== 'POST') { sendJson(response, 405, { error: 'This MCP endpoint uses stateless POST responses' }); return true; }
|
|
41
|
-
const server = createRoundtableMcpServer(service, actorFactory);
|
|
48
|
+
const server = createRoundtableMcpServer(service, actorFactory, { boundRoomId: roomId });
|
|
42
49
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
43
50
|
try {
|
|
44
51
|
await server.connect(transport);
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { fail, text } from './domain.js';
|
|
2
|
+
|
|
3
|
+
export const ROUNDTABLE_PROTOCOL = Object.freeze({
|
|
4
|
+
name: 'fuli-roundtable',
|
|
5
|
+
version: '1'
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
const MAX_PROFILE_CAPABILITIES = 32;
|
|
9
|
+
const MAX_CAPABILITY_BYTES = 128;
|
|
10
|
+
const MAX_QUERY_BYTES = 128;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Self profiles are public declarations. They never change the coordinator's
|
|
14
|
+
* seat role, runtime, execution permission or private Agent context.
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeSelfProfile(value) {
|
|
17
|
+
if (value === undefined || value === null) return null;
|
|
18
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
19
|
+
fail('invalid_self_profile', 'selfProfile must be an object');
|
|
20
|
+
}
|
|
21
|
+
const result = {};
|
|
22
|
+
if (value.responsibility !== undefined) result.responsibility = text(value.responsibility, 'selfProfile.responsibility', 2048);
|
|
23
|
+
if (value.introduction !== undefined) result.introduction = text(value.introduction, 'selfProfile.introduction', 2048);
|
|
24
|
+
if (value.capabilities !== undefined) {
|
|
25
|
+
if (!Array.isArray(value.capabilities) || value.capabilities.length > MAX_PROFILE_CAPABILITIES) {
|
|
26
|
+
fail('invalid_self_profile', `selfProfile.capabilities must contain at most ${MAX_PROFILE_CAPABILITIES} entries`);
|
|
27
|
+
}
|
|
28
|
+
const capabilities = value.capabilities.map((capability) => text(capability, 'selfProfile.capability', MAX_CAPABILITY_BYTES));
|
|
29
|
+
if (new Set(capabilities.map(capability => capability.toLocaleLowerCase())).size !== capabilities.length) {
|
|
30
|
+
fail('invalid_self_profile', 'selfProfile.capabilities must be unique');
|
|
31
|
+
}
|
|
32
|
+
result.capabilities = capabilities;
|
|
33
|
+
}
|
|
34
|
+
if (!Object.keys(result).length) fail('invalid_self_profile', 'selfProfile must declare responsibility, capabilities or introduction');
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function normalizeCapabilityQuery(value) {
|
|
39
|
+
if (value === undefined || value === null || value === '') return null;
|
|
40
|
+
return text(value, 'capabilityQuery', MAX_QUERY_BYTES).toLocaleLowerCase();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function matchesCapabilityQuery(seat, query) {
|
|
44
|
+
if (!query) return true;
|
|
45
|
+
const profile = seat.selfProfile;
|
|
46
|
+
return Array.isArray(profile?.capabilities) && profile.capabilities.some(capability => capability.toLocaleLowerCase().includes(query));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function publicRoster(seats, query = null) {
|
|
50
|
+
return seats.filter(seat => matchesCapabilityQuery(seat, query));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function nextRoundtableAction(room, seat) {
|
|
54
|
+
if (!seat.joinedAt) return { action: 'join', required: true, seatId: seat.id, reason: 'seat_not_joined' };
|
|
55
|
+
if (room.status === 'concluded' || room.status === 'cancelled' || room.status === 'failed') {
|
|
56
|
+
return { action: 'done', required: false, reason: room.status };
|
|
57
|
+
}
|
|
58
|
+
const turn = room.currentTurn;
|
|
59
|
+
if (turn) {
|
|
60
|
+
const ownTurn = turn.seatId === seat.id;
|
|
61
|
+
if (room.status !== 'active') {
|
|
62
|
+
return { action: 'wait', required: ownTurn, reason: room.stopReason ?? room.status, turnId: turn.id, seatId: turn.seatId, phase: turn.phase };
|
|
63
|
+
}
|
|
64
|
+
if (ownTurn && turn.status === 'pending') {
|
|
65
|
+
return { action: 'claim', required: true, turnId: turn.id, phase: turn.phase };
|
|
66
|
+
}
|
|
67
|
+
if (ownTurn && turn.status === 'claimed') {
|
|
68
|
+
return { action: 'submit', required: true, turnId: turn.id, attemptId: turn.attemptId, fence: turn.fence, deadline: turn.deadline, phase: turn.phase };
|
|
69
|
+
}
|
|
70
|
+
if (ownTurn) {
|
|
71
|
+
return { action: 'wait', required: true, reason: room.stopReason ?? turn.status, turnId: turn.id, seatId: turn.seatId, phase: turn.phase };
|
|
72
|
+
}
|
|
73
|
+
return { action: 'wait', required: false, reason: 'another_seat_turn', turnId: turn.id, seatId: turn.seatId, phase: turn.phase };
|
|
74
|
+
}
|
|
75
|
+
if (room.status === 'draft') return { action: 'wait', required: false, reason: 'room_not_started' };
|
|
76
|
+
return { action: 'wait', required: false, reason: room.stopReason ?? room.status };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const DISCOVERY_INSTRUCTIONS = Object.freeze([
|
|
80
|
+
'Join your invited seat with an optional public selfProfile describing your responsibility and capabilities.',
|
|
81
|
+
'Use capabilityQuery in discover_roundtable to find invited peers with matching declared capabilities.',
|
|
82
|
+
'Use message_roundtable for an addressed question or handoff; peer messages never claim or advance a turn.',
|
|
83
|
+
'When nextAction.action is claim, claim your own turn, follow its scoped context, and submit one final response.'
|
|
84
|
+
]);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import { createRoundtableStore } from './store.js';
|
|
3
3
|
import { assertMutable, budgetReason, event, fail, finishTurn, newRoom, normalizeLimits, publicRoom, publicSeat, publicTurn, scheduleTurn, TERMINAL_STATUSES, text } from './domain.js';
|
|
4
|
+
import { DISCOVERY_INSTRUCTIONS, ROUNDTABLE_PROTOCOL, nextRoundtableAction, normalizeCapabilityQuery, normalizeSelfProfile, publicRoster } from './profiles.js';
|
|
4
5
|
|
|
5
6
|
const PROCESS_BOOT_ID = randomUUID();
|
|
6
7
|
const closedIssuers = new Set();
|
|
@@ -224,11 +225,94 @@ export function createRoundtableService({ databasePath = ':memory:', clock = Dat
|
|
|
224
225
|
if (input.seatId && input.seatId !== actor.seatId) fail('seat_forbidden', 'Join only your authenticated seat', 403);
|
|
225
226
|
const seat = room.seats.find(s => s.id === actor.seatId);
|
|
226
227
|
if (room.currentTurn?.status === 'claimed' && room.currentTurn.seatId === seat.id && seat.sourceSessionId !== actor.sourceSessionId) fail('seat_busy', 'Seat has a current attempt in another session', 409);
|
|
227
|
-
|
|
228
|
+
const selfProfile = input.selfProfile === undefined ? seat.selfProfile : normalizeSelfProfile(input.selfProfile);
|
|
229
|
+
seat.joinedAt = timestamp; seat.joinedInvitationId = authenticated.get(actor).invitationId; seat.sourceApplication = actor.sourceApplication; seat.sourceSessionId = actor.sourceSessionId; seat.selfProfile = selfProfile;
|
|
228
230
|
event(room, 'seat_joined', timestamp, { seatId: seat.id, sourceApplication: actor.sourceApplication, sourceSessionId: actor.sourceSessionId, identityVerified: false });
|
|
229
231
|
return () => snapshot(room);
|
|
230
232
|
}, { participantOnly: true });
|
|
231
233
|
},
|
|
234
|
+
discover(input, actor) {
|
|
235
|
+
return transact(input, actor, (room) => {
|
|
236
|
+
const seat = room.seats.find(candidate => candidate.id === actor.seatId);
|
|
237
|
+
const capabilityQuery = normalizeCapabilityQuery(input.capabilityQuery ?? input.query);
|
|
238
|
+
const roster = publicRoster(room.seats, capabilityQuery).map(publicSeat);
|
|
239
|
+
const action = nextRoundtableAction(room, seat);
|
|
240
|
+
const roomView = {
|
|
241
|
+
id: room.id,
|
|
242
|
+
goal: room.goal,
|
|
243
|
+
mode: room.mode,
|
|
244
|
+
status: room.status,
|
|
245
|
+
phase: room.phase,
|
|
246
|
+
round: room.round,
|
|
247
|
+
stopReason: room.stopReason
|
|
248
|
+
};
|
|
249
|
+
return {
|
|
250
|
+
protocol: ROUNDTABLE_PROTOCOL,
|
|
251
|
+
protocolVersion: ROUNDTABLE_PROTOCOL.version,
|
|
252
|
+
version: ROUNDTABLE_PROTOCOL.version,
|
|
253
|
+
roomId: room.id,
|
|
254
|
+
room: roomView,
|
|
255
|
+
goal: room.goal,
|
|
256
|
+
mode: room.mode,
|
|
257
|
+
status: room.status,
|
|
258
|
+
phase: room.phase,
|
|
259
|
+
round: room.round,
|
|
260
|
+
ownSeat: publicSeat(seat),
|
|
261
|
+
roster,
|
|
262
|
+
capabilityQuery,
|
|
263
|
+
nextAction: action,
|
|
264
|
+
instructions: DISCOVERY_INSTRUCTIONS
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
},
|
|
268
|
+
message(input, actor) {
|
|
269
|
+
const body = text(input.body, 'body', 4096);
|
|
270
|
+
const key = text(input.idempotencyKey, 'idempotencyKey', 256);
|
|
271
|
+
return transact(input, actor, (room, timestamp) => {
|
|
272
|
+
assertMutable(room);
|
|
273
|
+
const sender = requireJoined(room, actor);
|
|
274
|
+
const targetSeatId = text(input.toSeatId ?? input.targetSeatId, 'toSeatId', 128);
|
|
275
|
+
const target = room.seats.find(seat => seat.id === targetSeatId);
|
|
276
|
+
if (!target) fail('seat_not_found', 'Target seat does not exist in this roundtable', 404);
|
|
277
|
+
const kind = input.kind ?? 'question';
|
|
278
|
+
if (!['question', 'handoff'].includes(kind)) fail('invalid_kind', 'Peer messages must be a question or handoff');
|
|
279
|
+
const replyTo = input.replyTo === undefined || input.replyTo === null ? null : text(input.replyTo, 'replyTo', 256);
|
|
280
|
+
if (replyTo && !store.messageById(room.id, replyTo)) fail('message_not_found', 'replyTo must reference a message in this roundtable', 404);
|
|
281
|
+
const fingerprint = inputHash({ roomId: room.id, targetSeatId, kind, body, replyTo });
|
|
282
|
+
const scope = `peer-message:${room.id}:${sender.id}`;
|
|
283
|
+
const duplicate = store.getIdempotent(scope, key, fingerprint);
|
|
284
|
+
if (duplicate) return duplicate;
|
|
285
|
+
const maxPeerMessages = room.limits.maxPeerMessages ?? 100;
|
|
286
|
+
const peerMessageCount = room.peerMessageCount ?? 0;
|
|
287
|
+
if (peerMessageCount >= maxPeerMessages) fail('peer_message_limit', 'Peer message budget is exhausted; required turns remain unaffected', 409);
|
|
288
|
+
const message = {
|
|
289
|
+
id: randomUUID(),
|
|
290
|
+
seq: room.nextSeq++,
|
|
291
|
+
seatId: sender.id,
|
|
292
|
+
senderSeatId: sender.id,
|
|
293
|
+
toSeatId: targetSeatId,
|
|
294
|
+
targetSeatId,
|
|
295
|
+
kind,
|
|
296
|
+
body,
|
|
297
|
+
replyTo,
|
|
298
|
+
artifacts: [],
|
|
299
|
+
verification: null,
|
|
300
|
+
actual: null,
|
|
301
|
+
status: 'peer',
|
|
302
|
+
createdAt: timestamp,
|
|
303
|
+
turnId: null,
|
|
304
|
+
attemptId: null,
|
|
305
|
+
sourceApplication: actor.sourceApplication,
|
|
306
|
+
sourceSessionId: actor.sourceSessionId
|
|
307
|
+
};
|
|
308
|
+
store.appendMessage(room.id, message);
|
|
309
|
+
room.peerMessageCount = peerMessageCount + 1;
|
|
310
|
+
event(room, 'peer_message', timestamp, { messageId: message.id, senderSeatId: sender.id, toSeatId: targetSeatId, targetSeatId, kind });
|
|
311
|
+
const result = { message, room: publicRoom(room), nextAction: nextRoundtableAction(room, sender) };
|
|
312
|
+
store.saveIdempotent(scope, key, fingerprint, result);
|
|
313
|
+
return result;
|
|
314
|
+
}, { participantOnly: true });
|
|
315
|
+
},
|
|
232
316
|
claim(input, actor) {
|
|
233
317
|
const result = transact(input, actor, (room, timestamp) => {
|
|
234
318
|
assertMutable(room); const seat = requireJoined(room, actor);
|
|
@@ -245,7 +329,7 @@ export function createRoundtableService({ databasePath = ':memory:', clock = Dat
|
|
|
245
329
|
for (const assigned of tasks) assigned.status = 'running';
|
|
246
330
|
event(room, 'turn_claimed', timestamp, { turnId: turn.id, seatId: seat.id, attemptId });
|
|
247
331
|
const messages = store.recentMessages(room.id, 12);
|
|
248
|
-
return { turnId: turn.id, attemptId, fence: turn.fence, deadline, context: { roomId: room.id, goal: room.goal, mode: room.mode, phase: room.phase, round: room.round, scope: room.scope, seat: publicSeat(seat), messages, tasks: structuredClone(room.tasks), currentTask: structuredClone(task ?? null), currentTasks: structuredClone(tasks), truncated: room.nextSeq - 1 > messages.length, earlierMessagesAvailable: room.nextSeq - 1 > messages.length } };
|
|
332
|
+
return { turnId: turn.id, attemptId, fence: turn.fence, deadline, context: { roomId: room.id, goal: room.goal, mode: room.mode, phase: room.phase, round: room.round, scope: room.scope, seat: publicSeat(seat), roster: room.seats.map(publicSeat), messages, tasks: structuredClone(room.tasks), currentTask: structuredClone(task ?? null), currentTasks: structuredClone(tasks), truncated: room.nextSeq - 1 > messages.length, earlierMessagesAvailable: room.nextSeq - 1 > messages.length } };
|
|
249
333
|
}, { participantOnly: true });
|
|
250
334
|
if (!result || !contextProvider || result.context.scope.kind !== 'fuli' || !result.context.seat.shareAgentContext) return result;
|
|
251
335
|
const addContext = context => {
|
package/src/roundtables/store.js
CHANGED
|
@@ -35,6 +35,10 @@ export function createRoundtableStore({ databasePath = ':memory:' } = {}) {
|
|
|
35
35
|
if (changed.changes !== 1) fail('revision_conflict', 'Roundtable changed; read the current revision', 409);
|
|
36
36
|
},
|
|
37
37
|
appendMessage(roomId, message) { database.prepare('INSERT INTO roundtable_messages(room_id,seq,id,data) VALUES(?,?,?,?)').run(roomId, message.seq, message.id, JSON.stringify(message)); },
|
|
38
|
+
messageById(roomId, id) {
|
|
39
|
+
const row = database.prepare('SELECT data FROM roundtable_messages WHERE room_id=? AND id=?').get(roomId, id);
|
|
40
|
+
return row ? JSON.parse(row.data) : null;
|
|
41
|
+
},
|
|
38
42
|
messages(roomId, afterSeq = 0, limit = 100) { return database.prepare('SELECT data FROM roundtable_messages WHERE room_id=? AND seq>? ORDER BY seq LIMIT ?').all(roomId, afterSeq, limit).map(row => JSON.parse(row.data)); },
|
|
39
43
|
recentMessages(roomId, limit = 12) { return database.prepare('SELECT data FROM roundtable_messages WHERE room_id=? ORDER BY seq DESC LIMIT ?').all(roomId, limit).reverse().map(row => JSON.parse(row.data)); },
|
|
40
44
|
recordLateEvidence(roomId, attemptId, evidence) { database.prepare('INSERT OR IGNORE INTO roundtable_late_evidence(room_id,attempt_id,data) VALUES(?,?,?)').run(roomId, attemptId, JSON.stringify(evidence)); },
|
|
@@ -4,12 +4,23 @@ const text = (maxLength = 256) => ({ type: 'string', minLength: 1, maxLength });
|
|
|
4
4
|
const common = { roomId: text(), seatToken: text(512) };
|
|
5
5
|
const tool = (name, description, properties, required = []) => ({ name, description,
|
|
6
6
|
inputSchema: { type: 'object', properties: { ...common, ...properties },
|
|
7
|
-
required: ['roomId', ...required], additionalProperties: false } });
|
|
7
|
+
required: ['roomId', 'seatToken', ...required], additionalProperties: false } });
|
|
8
|
+
const selfProfile = {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
responsibility: text(2048),
|
|
12
|
+
capabilities: { type: 'array', maxItems: 32, items: text(128) },
|
|
13
|
+
introduction: text(2048)
|
|
14
|
+
},
|
|
15
|
+
additionalProperties: false
|
|
16
|
+
};
|
|
8
17
|
|
|
9
18
|
export const ROUNDTABLE_TOOL_DEFINITIONS = [
|
|
19
|
+
tool('discover_roundtable', 'Discover the shared goal, current phase, your invited seat and public peers. Use capabilityQuery to search declared peer capabilities; the invitation limits this view to one room.',
|
|
20
|
+
{ capabilityQuery: text(128), query: text(128) }),
|
|
10
21
|
tool('read_roundtable', 'Read only the shared brief, messages and tasks of your invited Roundtable. A seat invitation is required; it does not grant private Fuli memory.',
|
|
11
22
|
{ after: { type: 'integer', minimum: 0 }, limit: { type: 'integer', minimum: 1, maximum: 100 } }),
|
|
12
|
-
tool('join_roundtable', 'Join your own invited seat
|
|
23
|
+
tool('join_roundtable', 'Join your own invited seat and optionally publish a bounded selfProfile describing your responsibility, capabilities and introduction. Self declared metadata never changes the coordinator seat role or grants private context.', { selfProfile }),
|
|
13
24
|
tool('claim_roundtable_turn', 'Claim only your assigned Roundtable turn. Null means wait. Do not perform work until a claim succeeds; respect deadline and scoped context.', {}),
|
|
14
25
|
tool('submit_roundtable_turn', 'Submit one final response for the claimed turn. Echo the exact attempt/fence; use the same idempotency key for uncertain retries. Artifacts and verification remain reported evidence.', {
|
|
15
26
|
turnId: text(), attemptId: text(), fence: { type: 'integer', minimum: 1 }, idempotencyKey: text(),
|
|
@@ -20,7 +31,12 @@ export const ROUNDTABLE_TOOL_DEFINITIONS = [
|
|
|
20
31
|
actual: { type: 'object', additionalProperties: true },
|
|
21
32
|
verification: { type: 'object', additionalProperties: true },
|
|
22
33
|
dissent: { type: 'array', maxItems: 16, items: text(1024) }
|
|
23
|
-
}, ['turnId', 'attemptId', 'fence', 'idempotencyKey', 'body'])
|
|
34
|
+
}, ['turnId', 'attemptId', 'fence', 'idempotencyKey', 'body']),
|
|
35
|
+
tool('message_roundtable', 'Send a room visible addressed question or handoff to a seat in this invited Roundtable. Peer messages are bounded and never claim, advance or grant a turn.', {
|
|
36
|
+
toSeatId: text(128), targetSeatId: text(128), body: text(4096),
|
|
37
|
+
kind: { type: 'string', enum: ['question', 'handoff'] },
|
|
38
|
+
idempotencyKey: text(256), replyTo: text(256)
|
|
39
|
+
}, ['toSeatId', 'body', 'idempotencyKey'])
|
|
24
40
|
];
|
|
25
41
|
export const ROUNDTABLE_TOOL_NAMES = ROUNDTABLE_TOOL_DEFINITIONS.map(({ name }) => name);
|
|
26
42
|
|
|
@@ -33,8 +49,9 @@ export async function callRoundtableTool(service, name, input, boundActor = null
|
|
|
33
49
|
});
|
|
34
50
|
// An actor is constructed by a transport, never accepted from client JSON.
|
|
35
51
|
const { seatToken: _secret, sourceApplication: _source, sourceSessionId: _session, actor: _actor, ...safe } = input;
|
|
36
|
-
|
|
37
|
-
|
|
52
|
+
if (boundActor && !safe.roomId) safe.roomId = boundActor.roomId;
|
|
53
|
+
const methods = { discover_roundtable: 'discover', read_roundtable: 'read', join_roundtable: 'join',
|
|
54
|
+
claim_roundtable_turn: 'claim', submit_roundtable_turn: 'submit', message_roundtable: 'message' };
|
|
38
55
|
const method = methods[name];
|
|
39
56
|
if (!method) throw new TypeError('Unknown Roundtable tool');
|
|
40
57
|
return service[method](safe, actor);
|
|
@@ -7,9 +7,12 @@ const phaseDirections = new Map([
|
|
|
7
7
|
]);
|
|
8
8
|
|
|
9
9
|
/** Shared task semantics; runtime-specific output formatting stays in adapters. */
|
|
10
|
+
export const ROUNDTABLE_WORKER_BOUNDARY = 'You are a bounded coordinated worker. The outer host owns task entry, memory checkpoints and the final user-visible reply. Return only the assigned roundtable turn result; this is not a final user-visible reply. Do not invoke task-entry or checkpoint tools, or report blocked solely because those host-owned lifecycle tools are unavailable.';
|
|
11
|
+
|
|
10
12
|
export function createRoundtableTaskPrompt(context, { allowWrite = false } = {}) {
|
|
11
13
|
return [
|
|
12
14
|
'You are one participant in a Fuli Agent Roundtable. Work only on your assigned current phase and tasks; the room goal describes the overall collaboration.',
|
|
15
|
+
ROUNDTABLE_WORKER_BOUNDARY,
|
|
13
16
|
`Current phase: ${context?.phase ?? 'unknown'}.`,
|
|
14
17
|
phaseDirections.get(context?.phase) ?? 'The current phase is unknown. Report blocked and request owner clarification.',
|
|
15
18
|
'Completion refers to this current turn, not the whole room. Blocked requires an actual obstacle to this turn, such as missing required input, authentication or permission. Do not report blocked solely because later phases or human acceptance are pending.',
|