fuli-context 0.12.0 → 0.12.2
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 +14 -6
- package/README.zh-CN.md +9 -4
- package/dist/web/assets/{AboutPage-BAD0iPVX.js → AboutPage-Dized8Uz.js} +1 -1
- package/dist/web/assets/{AgentConversations-1iGTd4sz.js → AgentConversations-Ccy41mEA.js} +1 -1
- package/dist/web/assets/{AgentHand-D_RafKOM.js → AgentHand-UzVhbKUC.js} +1 -1
- package/dist/web/assets/{AgentName-B5LKQ09q.js → AgentName-DJpvkub2.js} +1 -1
- package/dist/web/assets/AgentProfilePage-CFViQ8kj.js +1 -0
- package/dist/web/assets/AgentsDirectoryPage-CBFTg_IE.js +1 -0
- package/dist/web/assets/AgentsDirectoryPage-Cv-vxw2C.css +1 -0
- package/dist/web/assets/{ConnectionsPage-Tr2rRVOZ.js → ConnectionsPage-uXcabab_.js} +1 -1
- package/dist/web/assets/EmployeeRecruitDialog-D8Hvhc-M.js +1 -0
- package/dist/web/assets/EmployeeWorkbenchPage-TBl_HNj6.js +3 -0
- package/dist/web/assets/KnowledgeEditDialog-HmiqLC-Y.js +1 -0
- package/dist/web/assets/KnowledgeInspector-Cj2yK4Qh.js +1 -0
- package/dist/web/assets/KnowledgeOrganizerPage-DT3BROZm.js +1 -0
- package/dist/web/assets/KnowledgePage-C4J4VueL.js +1 -0
- package/dist/web/assets/KnowledgeWorkspace-DA_dqVRG.js +4 -0
- package/dist/web/assets/{NotFoundPage-fetSnp0N.js → NotFoundPage-x_Yd59mm.js} +1 -1
- package/dist/web/assets/{OverviewPage-BD4yY2Xz.js → OverviewPage-3eZjQ26v.js} +1 -1
- package/dist/web/assets/OverviewPage-8LBN8Shi.css +1 -0
- package/dist/web/assets/PersonalProfilePage-CDQk096L.js +1 -0
- package/dist/web/assets/PersonalProjectsPage-CJxp4Wyz.js +1 -0
- package/dist/web/assets/{ProjectAgentFirstTask-BcxqmKXQ.js → ProjectAgentFirstTask-BkRJ0zwO.js} +1 -1
- package/dist/web/assets/{ProjectAgentsPage-BWDJBESb.js → ProjectAgentsPage-BuNNqSEy.js} +8 -8
- package/dist/web/assets/{ProjectScopePicker-yodM7lF_.js → ProjectScopePicker-CiDxM2Yx.js} +1 -1
- package/dist/web/assets/PublicProjectsPage-ByOT559r.js +1 -0
- package/dist/web/assets/{ReviewPage-Cy0Rt4CO.js → ReviewPage-C0Vdr9uq.js} +1 -1
- package/dist/web/assets/RoundtablesPage-Drou-B81.css +1 -0
- package/dist/web/assets/RoundtablesPage-L3ShEWS3.js +1 -0
- package/dist/web/assets/SettingsPage-BE8pWbI6.css +1 -0
- package/dist/web/assets/SettingsPage-McYuinGz.js +1 -0
- package/dist/web/assets/{SharedJefaReportPage-C56kPciB.js → SharedJefaReportPage-Bg6lIk3E.js} +1 -1
- package/dist/web/assets/UiButton-x8hiHNRM.js +1 -0
- package/dist/web/assets/UiDialog-mjletK0y.js +1 -0
- package/dist/web/assets/{UiDisclosure-D0iMTplF.js → UiDisclosure-_IxeiexW.js} +1 -1
- package/dist/web/assets/{UiSegmented-DIJW6wv3.js → UiSegmented-WFfz9Evl.js} +1 -1
- package/dist/web/assets/{UiSelect-CMR0QqF6.js → UiSelect-K29jnV-1.js} +1 -1
- package/dist/web/assets/{WritingTastePage-QgNzssfF.js → WritingTastePage-DCULB-d2.js} +1 -1
- package/dist/web/assets/agent-pins-DS4mNsNt.js +1 -0
- package/dist/web/assets/agent-profile-Bh3MJGvb.css +1 -0
- package/dist/web/assets/{attention-store-B1DI8wmd.js → attention-store-CA5y8j1R.js} +1 -1
- package/dist/web/assets/catalog-D0VEfOAV.js +1 -0
- package/dist/web/assets/{client-CR-W5Wzp.js → client-DnwnUBQV.js} +1 -1
- package/dist/web/assets/{console-D7GHOMEC.js → console-Cw7TzOSC.js} +1 -1
- package/dist/web/assets/conversation-launcher-settings-Yc13viwr.js +1 -0
- package/dist/web/assets/{i18n-CVFhT_k7.js → i18n-oJFlt8hW.js} +3 -3
- package/dist/web/assets/{identity-CM6Dmy00.js → identity-DSGS4CZc.js} +1 -1
- package/dist/web/assets/index-BfHCcUL2.css +1 -0
- package/dist/web/assets/index-CxNER7Oo.js +5 -0
- package/dist/web/assets/{judgment-types-CJBEyJs1.js → judgment-types-DR931cZ7.js} +1 -1
- package/dist/web/assets/{model-WFhQZ3v_.js → model-BJ-UF4cs.js} +1 -1
- package/dist/web/assets/{personal-profile-graph-BwCh2M9G.js → personal-profile-graph-SOK0BR0w.js} +1 -1
- package/dist/web/assets/project-team-api-C_0qJk_B.js +1 -0
- package/dist/web/assets/{shared-report-CeIvrL9a.js → shared-report-CiX64ZiD.js} +1 -1
- package/dist/web/assets/useAgentRoster-B84mzZIg.js +1 -0
- package/dist/web/assets/{useApi-CROJJdhE-BYhbjaZB.js → useApi-CROJJdhE-7gt0QyQ5.js} +1 -1
- package/dist/web/assets/{vue-router-DaxrQwX3.js → vue-router-CCvPGaLD.js} +1 -1
- package/dist/web/index.html +15 -14
- package/dist/web/licenses/ant-design-icons.txt +22 -0
- package/docs/agent-conversations-and-collaboration.md +3 -3
- package/docs/agent-roundtable.md +33 -0
- package/docs/client-connections.md +108 -0
- package/docs/employee-agents.md +3 -3
- package/docs/project-agent-memory.md +6 -6
- package/graph-provider/fuli_graph/default_project_lead.py +1 -1
- package/graph-provider/fuli_graph/project_agent_delegations.py +70 -2
- package/graph-provider/fuli_graph/project_agent_memory_routes.py +18 -1
- package/graph-provider/fuli_graph/project_agent_remote_origins.py +154 -0
- package/graph-provider/fuli_graph/project_agent_task_models.py +1 -1
- package/graph-provider/fuli_graph/runtime.py +7 -0
- package/graph-provider/fuli_graph/store_project_agent_context.py +3 -2
- package/graph-provider/fuli_graph/store_project_agent_coordination_policy.py +1 -1
- package/graph-provider/fuli_graph/store_project_agent_task_reads.py +1 -1
- package/graph-provider/fuli_graph/store_project_agent_task_recruitment.py +5 -5
- package/graph-provider/fuli_graph/store_project_agent_task_staffing.py +2 -2
- package/graph-provider/fuli_graph/store_project_agent_tasks.py +2 -2
- package/graph-provider/fuli_graph/store_project_agents.py +6 -6
- package/graph-provider/fuli_graph/system_agents.py +12 -2
- package/graph-provider/fuli_graph/system_hr_identity.py +2 -2
- package/graph-provider/pyproject.toml +2 -1
- package/npm-shrinkwrap.json +280 -5
- package/package.json +6 -2
- package/skills/operating-fuli/SKILL.md +3 -3
- package/src/agent-peer/coordinator-http.js +117 -0
- package/src/agent-peer/coordinator-service.js +258 -0
- package/src/agent-peer/coordinator-store.js +95 -0
- package/src/agent-peer/device-identity.js +70 -0
- package/src/agent-peer/http.js +47 -0
- package/src/agent-peer/node-store.js +127 -0
- package/src/agent-peer/origin-proof.js +56 -0
- package/src/agent-peer/peer-port.js +140 -0
- package/src/agent-peer/peer-runtime.js +298 -0
- package/src/agent-peer/peer-transport.js +78 -0
- package/src/agent-peer/protocol.js +161 -0
- package/src/agent-peer/runner.js +251 -0
- package/src/agent-roundtable/http.js +17 -3
- package/src/agent-roundtable/service.js +272 -43
- package/src/agent-roundtable/tools.js +15 -1
- package/src/agent-roundtable/wake.js +12 -3
- package/src/agent-tools/graph-definitions.js +14 -5
- package/src/agent-tools/graph-handlers.js +1 -0
- package/src/agent-tools/interface-catalog.js +10 -1
- package/src/agent-tools/project-agent-definitions.js +1 -1
- package/src/agents/identity-receipt.js +1 -1
- package/src/cli/command-registry.js +3 -0
- package/src/cli/connect-command.js +31 -0
- package/src/cli/mcp-command.js +101 -0
- package/src/cli/peer-command.js +100 -0
- package/src/cli/setup-agent-selection.js +2 -2
- package/src/cli/setup-command.js +12 -5
- package/src/cli.js +16 -0
- package/src/employees/catalog/bole.json +2 -2
- package/src/graphiti/federated-application.js +4 -1
- package/src/graphiti/project-agent-control-plane.js +7 -0
- package/src/graphiti/project-agent-task-entry.js +4 -4
- package/src/graphiti/project-agent-team-context.js +16 -18
- package/src/graphiti/project-team-view.js +82 -0
- package/src/graphiti/provider-client.js +9 -0
- package/src/graphiti/task-entry-preferences.js +1 -1
- package/src/http/api-router.js +10 -1
- package/src/http/graph-api-router.js +7 -0
- package/src/mcp/runtime.js +2 -1
- package/src/mcp/tool-annotations.js +4 -3
- package/src/mcp/tool-profiles.js +36 -0
- package/src/mcp-server.js +2 -100
- package/src/server.js +14 -2
- package/src/setup/agent-installation-status.js +5 -3
- package/src/setup/agents.js +23 -3
- package/src/setup/claude-desktop-config.js +60 -0
- package/src/setup/client-connection.js +25 -0
- package/src/setup/paths.js +3 -1
- package/src/setup/setup.js +1 -1
- package/src/setup/stable-package-root.js +29 -0
- package/src/system/system-service.js +5 -0
- package/dist/web/assets/AgentProfilePage-CioOdkBM.js +0 -1
- package/dist/web/assets/AgentsDirectoryPage--1A9nvNA.js +0 -1
- package/dist/web/assets/AgentsDirectoryPage-C7OUQH9v.css +0 -1
- package/dist/web/assets/EmployeeRecruitDialog-1QevD4Qv.js +0 -1
- package/dist/web/assets/EmployeeWorkbenchPage-B0vQIgwA.js +0 -3
- package/dist/web/assets/KnowledgeEditDialog-ClnrfPZi.js +0 -1
- package/dist/web/assets/KnowledgeInspector-Te1hJZbx.js +0 -1
- package/dist/web/assets/KnowledgeOrganizerPage-CCCrWl6Y.js +0 -1
- package/dist/web/assets/KnowledgePage-DGOcBwr8.js +0 -1
- package/dist/web/assets/KnowledgeWorkspace-cKn1NCRe.js +0 -4
- package/dist/web/assets/OverviewPage-By-fUzSG.css +0 -1
- package/dist/web/assets/PersonalProfilePage-DGJVP_VB.js +0 -1
- package/dist/web/assets/PersonalProjectsPage-D25HYMgD.js +0 -1
- package/dist/web/assets/PublicProjectsPage-CyNxVzW3.js +0 -1
- package/dist/web/assets/RoundtablesPage-BjVhvwMc.js +0 -1
- package/dist/web/assets/RoundtablesPage-Cb0R4GaM.css +0 -1
- package/dist/web/assets/SettingsPage-CCZqZtnH.js +0 -1
- package/dist/web/assets/SettingsPage-DYz5bTwa.css +0 -1
- package/dist/web/assets/UiDialog-CvDxg3B1.js +0 -1
- package/dist/web/assets/agent-pins-BcJ_qr8n.js +0 -1
- package/dist/web/assets/agent-profile-BZj3ezWe.css +0 -1
- package/dist/web/assets/catalog-DbXv0_bR.js +0 -1
- package/dist/web/assets/conversation-launcher-settings-Dj2GMzCI.js +0 -1
- package/dist/web/assets/index-CJ6Jmok9.css +0 -1
- package/dist/web/assets/index-CRa3GnYi.js +0 -5
- package/dist/web/assets/useAgentRoster-DCArHlxK.js +0 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto';
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
import { ApplicationError, ApplicationErrorCode } from '../app/application-error.js';
|
|
4
4
|
import { WAKE_CLIENTS, resolveClientCommand, sessionWorkingDirectory, wakeAgent } from './wake.js';
|
|
@@ -6,6 +6,8 @@ import { WAKE_CLIENTS, resolveClientCommand, sessionWorkingDirectory, wakeAgent
|
|
|
6
6
|
const CLIENT_LABELS = { claude_code: 'Claude Code', codex: 'Codex', cursor: 'Cursor', claude: 'Claude', other: 'Agent' };
|
|
7
7
|
const THREAD_MESSAGE_LIMIT = 24;
|
|
8
8
|
const NESTED_ASK_LIMIT = 3;
|
|
9
|
+
const REMOTE_PENDING = new Set(['sent', 'queued', 'claimed', 'running']);
|
|
10
|
+
const REMOTE_FINAL = new Set(['answered', 'failed', 'cancelled', 'expired', 'unknown']);
|
|
9
11
|
|
|
10
12
|
// Agents talk to each other directly: an ask wakes the recipient in its own
|
|
11
13
|
// client and returns its answer, or waits in its inbox for its next task.
|
|
@@ -14,6 +16,8 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
|
|
|
14
16
|
const spaceId = () => app.config.personal.spaceId;
|
|
15
17
|
// Opened on first use: most MCP processes never touch the roundtable.
|
|
16
18
|
let opened = null;
|
|
19
|
+
const refreshes = new Map();
|
|
20
|
+
let refreshOffset = 0;
|
|
17
21
|
const store = new Proxy({}, { get: (_target, property) => (opened ??= openStore())[property] });
|
|
18
22
|
|
|
19
23
|
async function sender({ taskContextToken, sourceApplication }) {
|
|
@@ -60,18 +64,24 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
|
|
|
60
64
|
agent.employeeNumber ? `FLA ${agent.employeeNumber}` : null, agent.profile.displayName,
|
|
61
65
|
agent.profile.name, agent.profile.responsibility, ...(agent.profile.capabilities ?? [])]
|
|
62
66
|
.some((value) => value?.toLowerCase().includes(needle)));
|
|
63
|
-
|
|
67
|
+
const local = await Promise.all(matches.slice(0, 20).map(async (agent) => ({
|
|
64
68
|
agentId: agent.agentId, employeeNumber: agent.employeeNumber, name: label(agent), responsibility: agent.profile.responsibility,
|
|
65
69
|
clients: (agent.profile.allowedClients ?? []).filter((client) => WAKE_CLIENTS.includes(client)),
|
|
66
70
|
recentConversations: await conversations(agent, personalProjectId)
|
|
67
|
-
})))
|
|
68
|
-
|
|
71
|
+
})));
|
|
72
|
+
const remote = app.peer ? await app.peer.remoteAgents({ query: needle }).catch(() => null) : null;
|
|
73
|
+
return { agents: local,
|
|
74
|
+
...(remote ? { remoteAgents: remote.agents, ...(remote.unavailable ? { remoteUnavailable: remote.unavailable } : {}) } : {}),
|
|
75
|
+
guidance: remote
|
|
76
|
+
? 'message_agent sends to an Agent (optionally one of its recentConversations) and returns its answer. remoteAgents run on other paired devices (LAN roundtable Beta): address them by their exact address, from an active project task. Only each shared project\'s lead can be asked. Use get_agent_message_status for a pending reply.'
|
|
77
|
+
: 'message_agent sends to an Agent (optionally one of its recentConversations) and returns its answer.' };
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
// Ordered attempts: the chosen conversation (or the Agent's latest one), then a
|
|
72
81
|
// fresh session with the Agent's memory in that client and in its other clients.
|
|
73
|
-
async function deliveryTargets(agent, conversation, projectId, projectPath) {
|
|
74
|
-
const allowed = (agent.profile.allowedClients ?? []).filter((client) => WAKE_CLIENTS.includes(client)
|
|
82
|
+
async function deliveryTargets(agent, conversation, projectId, projectPath, { clients = null, freshCwd = null, strictCwd = false } = {}) {
|
|
83
|
+
const allowed = (agent.profile.allowedClients ?? []).filter((client) => WAKE_CLIENTS.includes(client)
|
|
84
|
+
&& (!clients || clients.includes(client)) && clientAvailable(client));
|
|
75
85
|
const explicit = Boolean(conversation) && !['auto', 'new'].includes(conversation);
|
|
76
86
|
let resumable = null;
|
|
77
87
|
if (conversation !== 'new') {
|
|
@@ -89,49 +99,39 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
|
|
|
89
99
|
const freshClients = resumable
|
|
90
100
|
? [resumable.client, ...(explicit ? [] : allowed.filter((client) => client !== resumable.client))]
|
|
91
101
|
: WAKE_CLIENTS.filter((client) => allowed.includes(client));
|
|
102
|
+
// A strict delivery only starts new sessions in a folder known for this project.
|
|
103
|
+
const strictFolder = strictCwd ? freshCwd ?? resumable?.cwd ?? await projectFolder(agent, projectId) : null;
|
|
104
|
+
if (strictCwd && !strictFolder) return resumable ? [resumable] : [];
|
|
92
105
|
const fresh = freshClients.map((client) => ({ client, sessionId: null,
|
|
93
|
-
cwd: client === resumable?.client ? resumable.cwd
|
|
106
|
+
cwd: client === resumable?.client ? resumable.cwd
|
|
107
|
+
: strictCwd ? strictFolder : freshCwd ?? projectPath ?? resumable?.cwd ?? process.cwd() }));
|
|
94
108
|
return resumable ? [resumable, ...fresh] : fresh;
|
|
95
109
|
}
|
|
96
110
|
|
|
97
|
-
async function
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
from: { agentId: from.agent?.agentId, name: from.agent ? label(from.agent) : CLIENT_LABELS[from.client] ?? from.client,
|
|
110
|
-
client: from.client, session: from.session },
|
|
111
|
-
to: { agentId: target.agentId, name: label(target) } });
|
|
112
|
-
const nested = history.filter((message) => message.kind === 'ask' && message.status === 'delivering').length;
|
|
113
|
-
if (!wait || nested >= NESTED_ASK_LIMIT) return queued(thread, ask, target, nested ? 'nested_limit' : 'not_waiting');
|
|
114
|
-
if (!taskContextToken || !from.projectId) return queued(thread, ask, target, 'task_context_required');
|
|
115
|
-
const attempts = await deliveryTargets(target, conversation, from.projectId, projectPath);
|
|
116
|
-
if (!attempts.length) return queued(thread, ask, target, 'no_client');
|
|
117
|
-
const prompt = wakePrompt({ thread, ask, target, senderName: ask.from_name, senderClient: from.client });
|
|
118
|
-
const timeoutMs = Math.min(Math.max(timeoutSeconds, 30), 900) * 1000;
|
|
111
|
+
async function projectFolder(agent, projectId) {
|
|
112
|
+
for (const session of await conversations(agent, projectId)) {
|
|
113
|
+
if (session.projectId !== projectId) continue;
|
|
114
|
+
const cwd = locateCwd(session.client, session.sessionId);
|
|
115
|
+
if (cwd) return cwd;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Tries each target until one answers as the restored Agent. The grant is the
|
|
121
|
+
// only authority the woken session receives and is revoked after every attempt.
|
|
122
|
+
async function deliver({ ask, attempts, prompt, timeoutMs, issueGrant, signal, onSpawn, peerDepth }) {
|
|
119
123
|
const unusable = new Set();
|
|
120
124
|
let failure = 'wake_failed';
|
|
121
125
|
for (const delivery of attempts) {
|
|
122
126
|
if (unusable.has(delivery.client)) continue;
|
|
127
|
+
if (signal?.aborted) { failure = 'cancelled'; break; }
|
|
123
128
|
store.updateMessage(ask.id, { status: 'delivering', toClient: delivery.client, toSession: delivery.sessionId });
|
|
124
129
|
let answer;
|
|
125
130
|
let grant;
|
|
126
131
|
try {
|
|
127
|
-
grant = await
|
|
128
|
-
task_context_token: taskContextToken, source_application: from.client, agent_id: target.agentId,
|
|
129
|
-
target_application: delivery.client,
|
|
130
|
-
// Claude resumes into a fork with a new ID. Only Codex resumes in place.
|
|
131
|
-
target_session_id: delivery.client === 'codex' ? delivery.sessionId : null,
|
|
132
|
-
lifetime_seconds: timeoutMs / 1000 });
|
|
132
|
+
grant = await issueGrant(delivery, timeoutMs / 1000);
|
|
133
133
|
answer = await wake({ client: delivery.client, sessionId: delivery.sessionId, cwd: delivery.cwd,
|
|
134
|
-
timeoutMs, prompt, delegationToken: grant.token });
|
|
134
|
+
timeoutMs, prompt, delegationToken: grant.token, peerDepth, signal, onSpawn });
|
|
135
135
|
const verified = answer.sessionId && await app.personal.verifyAgentDelegation({
|
|
136
136
|
personal_space_id: spaceId(), token: grant.token, session_id: answer.sessionId });
|
|
137
137
|
if (!verified?.redeemed) throw Object.assign(new Error('The receiving session did not restore the Agent'),
|
|
@@ -141,8 +141,8 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
|
|
|
141
141
|
// A missing or logged-out client cannot answer from any session. A conversation
|
|
142
142
|
// open in its app, or a sub-agent thread, still allows a new session.
|
|
143
143
|
if (['client_login_required', 'client_unavailable'].includes(failure)) unusable.add(delivery.client);
|
|
144
|
-
// A timeout already used the caller's wait.
|
|
145
|
-
if (failure === 'wake_timeout') break;
|
|
144
|
+
// A timeout already used the caller's wait; a cancellation ends the delivery.
|
|
145
|
+
if (failure === 'wake_timeout' || failure === 'cancelled') break;
|
|
146
146
|
continue;
|
|
147
147
|
} finally {
|
|
148
148
|
// Even failed or timed-out attempts lose their authority. Expiry remains
|
|
@@ -150,16 +150,209 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
|
|
|
150
150
|
if (grant?.token) await app.personal.revokeAgentDelegation({
|
|
151
151
|
personal_space_id: spaceId(), token: grant.token }).catch(() => {});
|
|
152
152
|
}
|
|
153
|
-
|
|
153
|
+
return { answer, delivery, via: `${delivery.client}:${delivery.sessionId ? 'resume' : 'new'}` };
|
|
154
|
+
}
|
|
155
|
+
throw Object.assign(new Error('No client answered'), { code: failure });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function messageAgent({ taskContextToken = null, sourceApplication, to, body, threadId = null,
|
|
159
|
+
conversation = 'auto', wait = true, timeoutSeconds = 300, projectPath = null } = {}) {
|
|
160
|
+
if (app.peer?.isRemoteAddress(to)) {
|
|
161
|
+
return messageRemote({ taskContextToken, sourceApplication, to, body, threadId, conversation, wait, timeoutSeconds });
|
|
162
|
+
}
|
|
163
|
+
const from = await sender({ taskContextToken, sourceApplication });
|
|
164
|
+
const target = await recipient(to);
|
|
165
|
+
if (from.agent?.agentId === target.agentId && !threadId) throw validation('An Agent cannot open a thread with itself');
|
|
166
|
+
const { thread, history } = openThread(threadId, from.projectId, body);
|
|
167
|
+
const ask = store.appendMessage(thread.id, { id: idFactory(), kind: 'ask', body, status: 'queued',
|
|
168
|
+
from: { agentId: from.agent?.agentId, name: from.agent ? label(from.agent) : CLIENT_LABELS[from.client] ?? from.client,
|
|
169
|
+
client: from.client, session: from.session },
|
|
170
|
+
to: { agentId: target.agentId, name: label(target) } });
|
|
171
|
+
const nested = history.filter((message) => message.kind === 'ask' && message.status === 'delivering').length;
|
|
172
|
+
if (!wait || nested >= NESTED_ASK_LIMIT) return queued(thread, ask, target, nested ? 'nested_limit' : 'not_waiting');
|
|
173
|
+
if (!taskContextToken || !from.projectId) return queued(thread, ask, target, 'task_context_required');
|
|
174
|
+
const attempts = await deliveryTargets(target, conversation, from.projectId, projectPath);
|
|
175
|
+
if (!attempts.length) return queued(thread, ask, target, 'no_client');
|
|
176
|
+
const prompt = wakePrompt({ thread, ask, target, senderName: ask.from_name, senderClient: from.client });
|
|
177
|
+
const timeoutMs = Math.min(Math.max(timeoutSeconds, 30), 900) * 1000;
|
|
178
|
+
try {
|
|
179
|
+
const { answer, delivery, via } = await deliver({ ask, attempts, prompt, timeoutMs,
|
|
180
|
+
issueGrant: (delivery, lifetime) => app.personal.issueAgentDelegation({ personal_space_id: spaceId(),
|
|
181
|
+
task_context_token: taskContextToken, source_application: from.client, agent_id: target.agentId,
|
|
182
|
+
target_application: delivery.client,
|
|
183
|
+
// Claude resumes into a fork with a new ID. Only Codex resumes in place.
|
|
184
|
+
target_session_id: delivery.client === 'codex' ? delivery.sessionId : null,
|
|
185
|
+
lifetime_seconds: lifetime }) });
|
|
154
186
|
store.updateMessage(ask.id, { status: 'answered', via });
|
|
155
187
|
const reply = store.appendMessage(thread.id, { id: idFactory(), kind: 'reply', body: answer.body, status: 'sent',
|
|
156
188
|
inReplyTo: ask.id, via, from: { agentId: target.agentId, name: label(target), client: delivery.client,
|
|
157
189
|
session: answer.sessionId }, to: { agentId: from.agent?.agentId, name: ask.from_name, client: from.client, session: from.session } });
|
|
158
190
|
return { status: 'answered', threadId: thread.id, messageId: ask.id, recipient: { agentId: target.agentId, name: label(target) },
|
|
159
191
|
via, reply: reply.body };
|
|
192
|
+
} catch (error) {
|
|
193
|
+
store.updateMessage(ask.id, { status: 'queued', error: error.code ?? 'wake_failed' });
|
|
194
|
+
return queued(thread, ask, target, error.code ?? 'wake_failed');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function openThread(threadId, projectId, body) {
|
|
199
|
+
let thread = threadId ? store.getThread(threadId) : null;
|
|
200
|
+
if (threadId && (!thread || thread.space_id !== spaceId())) throw validation('Thread not found');
|
|
201
|
+
const history = thread ? store.messages(thread.id) : [];
|
|
202
|
+
if (history.length >= THREAD_MESSAGE_LIMIT) throw validation('This thread reached its message limit; start a new one');
|
|
203
|
+
thread ??= store.createThread({ id: idFactory(), spaceId: spaceId(), projectId,
|
|
204
|
+
subject: body.trim().split(/\r?\n/)[0].slice(0, 80) });
|
|
205
|
+
return { thread, history };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A remote ask is signed by this device for one verified local task. The task
|
|
209
|
+
// token itself never leaves this computer.
|
|
210
|
+
async function messageRemote({ taskContextToken, sourceApplication, to, body, threadId, conversation, wait, timeoutSeconds }) {
|
|
211
|
+
if (!['auto', 'new'].includes(conversation ?? 'auto')) throw validation('Remote Agents accept conversation "auto" or "new"');
|
|
212
|
+
const from = await sender({ taskContextToken, sourceApplication });
|
|
213
|
+
if (!from.agent || !from.projectId) throw validation('A remote message must come from an active project task of a FULI Agent; pass taskContextToken');
|
|
214
|
+
const target = await app.peer.resolveRemote(to);
|
|
215
|
+
const { thread } = openThread(threadId, from.projectId, body);
|
|
216
|
+
if (thread.project_id !== from.projectId) throw validation('This thread belongs to another project');
|
|
217
|
+
const ask = store.appendMessage(thread.id, { id: idFactory(), kind: 'ask', body, status: 'sent', via: 'peer',
|
|
218
|
+
from: { agentId: from.agent.agentId, name: label(from.agent), client: from.client, session: from.session },
|
|
219
|
+
to: { agentId: target.address, name: `${target.name} · ${target.deviceName}` } });
|
|
220
|
+
let state;
|
|
221
|
+
try {
|
|
222
|
+
state = await app.peer.send({ messageId: ask.id, threadId: thread.id, target, body,
|
|
223
|
+
conversation: conversation ?? 'auto', depth: peerDepth(env) + 1, taskContextToken, sourceApplication: from.client,
|
|
224
|
+
from: { agentId: from.agent.agentId, name: label(from.agent), client: from.client } });
|
|
225
|
+
} catch (error) {
|
|
226
|
+
store.updateMessage(ask.id, { status: 'failed', error: error.code ?? 'peer_send_failed' });
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
if (wait) state = await app.peer.waitFor(ask.id, Math.min(Math.max(timeoutSeconds, 30), 900) * 1000);
|
|
230
|
+
return remoteView(applyRemote(ask.id, state));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function applyRemote(messageId, state) {
|
|
234
|
+
const ask = store.getMessage(messageId);
|
|
235
|
+
if (!ask || !state || !REMOTE_PENDING.has(ask.status)) return ask;
|
|
236
|
+
const status = state.status === 'queued' ? 'sent' : state.status;
|
|
237
|
+
if (status === 'answered' && state.reply) {
|
|
238
|
+
store.updateMessage(ask.id, { status: 'answered', via: state.via ?? 'peer' });
|
|
239
|
+
store.appendMessage(ask.thread_id, { id: idFactory(), kind: 'reply', body: state.reply, status: 'sent', inReplyTo: ask.id,
|
|
240
|
+
via: state.via ?? 'peer', from: { agentId: ask.to_agent, name: ask.to_name, client: state.via?.split(':')[0] ?? null },
|
|
241
|
+
to: { agentId: ask.from_agent, name: ask.from_name, client: ask.from_client, session: ask.from_session } });
|
|
242
|
+
} else if (status !== ask.status) {
|
|
243
|
+
store.updateMessage(ask.id, { status, error: state.error ?? null });
|
|
160
244
|
}
|
|
161
|
-
store.
|
|
162
|
-
|
|
245
|
+
return store.getMessage(ask.id);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function remoteView(ask) {
|
|
249
|
+
const reply = ask.status === 'answered'
|
|
250
|
+
? store.messages(ask.thread_id).find((message) => message.in_reply_to === ask.id) : null;
|
|
251
|
+
return { status: ask.status === 'sent' ? 'queued' : ask.status, threadId: ask.thread_id, messageId: ask.id,
|
|
252
|
+
recipient: { agentId: ask.to_agent, name: ask.to_name }, remote: true,
|
|
253
|
+
...(reply ? { reply: reply.body, via: reply.via } : {}), ...(ask.error ? { reason: ask.error } : {}),
|
|
254
|
+
...(REMOTE_FINAL.has(ask.status) ? {} : { guidance: 'The remote device has not answered yet. Check later with get_agent_message_status; it does not mean the work ran.' }),
|
|
255
|
+
...(ask.status === 'unknown' ? { guidance: 'The remote device lost track of this attempt; it may or may not have run. It is not retried automatically.' } : {}) };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Runs on the receiving device for one claimed, locally authorized remote ask.
|
|
259
|
+
async function answerRemote({ claim, share, prompt: promptOverride = null, issueGrant, signal, onSpawn, timeoutMs }) {
|
|
260
|
+
const { assertion, sender: device } = claim;
|
|
261
|
+
if (share.spaceId !== spaceId()) throw Object.assign(new Error('The share belongs to another space'), { code: 'share_unavailable' });
|
|
262
|
+
const target = await agentById(share.agentId);
|
|
263
|
+
if (!target) throw Object.assign(new Error('The shared Agent is no longer active'), { code: 'agent_unavailable' });
|
|
264
|
+
// Remote IDs are only meaningful together with their device and this share,
|
|
265
|
+
// so they never address an existing local row.
|
|
266
|
+
const scope = [device.nodeId, share.spaceId, share.projectId, share.bindingId, share.agentId];
|
|
267
|
+
const threadId = remoteRowId('thread', [...scope, assertion.threadId]);
|
|
268
|
+
const askId = remoteRowId('ask', [...scope, assertion.messageId]);
|
|
269
|
+
if (store.getMessage(askId)) {
|
|
270
|
+
throw Object.assign(new Error('This remote message was already delivered here'), { code: 'already_delivered' });
|
|
271
|
+
}
|
|
272
|
+
const thread = store.getThread(threadId) ?? store.createThread({ id: threadId, spaceId: share.spaceId,
|
|
273
|
+
projectId: share.projectId, subject: assertion.body.trim().split(/\r?\n/)[0].slice(0, 80) });
|
|
274
|
+
const ask = store.appendMessage(thread.id, { id: askId, kind: 'ask',
|
|
275
|
+
body: assertion.body, status: 'delivering', via: 'peer',
|
|
276
|
+
from: { agentId: `peer:${device.nodeId.slice(0, 16)}:${assertion.from.agentId}`, name: `${assertion.from.name} · ${device.name}`,
|
|
277
|
+
client: assertion.from.client ?? 'other' },
|
|
278
|
+
to: { agentId: target.agentId, name: label(target) } });
|
|
279
|
+
const attempts = await deliveryTargets(target, assertion.conversation, share.projectId, null,
|
|
280
|
+
{ clients: share.clients, freshCwd: share.workingDirectory ?? null, strictCwd: true });
|
|
281
|
+
if (!attempts.length) {
|
|
282
|
+
const code = share.clients.some((client) => clientAvailable(client)) ? 'no_working_directory' : 'no_client';
|
|
283
|
+
store.updateMessage(ask.id, { status: 'failed', error: code });
|
|
284
|
+
throw Object.assign(new Error(code === 'no_working_directory'
|
|
285
|
+
? 'This shared project has no known folder on this device; the owner sets one in Settings'
|
|
286
|
+
: 'No shared client can run on this device'), { code });
|
|
287
|
+
}
|
|
288
|
+
const prompt = promptOverride ?? wakePrompt({ thread, ask, target, senderName: ask.from_name, senderClient: ask.from_client });
|
|
289
|
+
try {
|
|
290
|
+
const { answer, delivery, via } = await deliver({ ask, attempts, prompt, timeoutMs, issueGrant, signal, onSpawn,
|
|
291
|
+
peerDepth: assertion.depth });
|
|
292
|
+
store.updateMessage(ask.id, { status: 'answered', via });
|
|
293
|
+
store.appendMessage(thread.id, { id: idFactory(), kind: 'reply', body: answer.body, status: 'sent', inReplyTo: ask.id, via,
|
|
294
|
+
from: { agentId: target.agentId, name: label(target), client: delivery.client, session: answer.sessionId },
|
|
295
|
+
to: { agentId: ask.from_agent, name: ask.from_name, client: ask.from_client } });
|
|
296
|
+
return { body: answer.body, via, sessionId: answer.sessionId };
|
|
297
|
+
} catch (error) {
|
|
298
|
+
store.updateMessage(ask.id, { status: error.code === 'cancelled' ? 'cancelled' : 'failed', error: error.code ?? 'wake_failed' });
|
|
299
|
+
throw error;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function messageStatus({ taskContextToken, sourceApplication, messageId }) {
|
|
304
|
+
const me = await sender({ taskContextToken, sourceApplication });
|
|
305
|
+
const ask = taskScopedAsk(me, messageId);
|
|
306
|
+
if (![ask.from_agent, ask.to_agent].includes(me.agent.agentId)) throw validation('No such message for this Agent');
|
|
307
|
+
return statusOf(ask);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function statusOf(ask) {
|
|
311
|
+
if (REMOTE_PENDING.has(ask.status) && app.peer?.hasSent(ask.id)) {
|
|
312
|
+
const state = await app.peer.status(ask.id).catch(() => null);
|
|
313
|
+
return remoteView(applyRemote(ask.id, state) ?? ask);
|
|
314
|
+
}
|
|
315
|
+
if (ask.via === 'peer' || app.peer?.hasSent(ask.id)) return remoteView(ask);
|
|
316
|
+
const reply = store.messages(ask.thread_id).find((message) => message.in_reply_to === ask.id);
|
|
317
|
+
return { status: ask.status, threadId: ask.thread_id, messageId: ask.id,
|
|
318
|
+
recipient: { agentId: ask.to_agent, name: ask.to_name }, ...(reply ? { reply: reply.body, via: reply.via } : {}),
|
|
319
|
+
...(ask.error ? { reason: ask.error } : {}) };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
async function cancelMessage({ taskContextToken, sourceApplication, messageId }) {
|
|
323
|
+
const me = await sender({ taskContextToken, sourceApplication });
|
|
324
|
+
const ask = taskScopedAsk(me, messageId);
|
|
325
|
+
if (ask.from_agent !== me.agent.agentId) throw validation('No such message sent by this Agent');
|
|
326
|
+
return cancelAsk(ask);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// The same Agent ID can work in several projects; a task only reaches its own project's messages.
|
|
330
|
+
function taskScopedAsk(me, messageId) {
|
|
331
|
+
const ask = store.getMessage(messageId);
|
|
332
|
+
const thread = ask && store.getThread(ask.thread_id);
|
|
333
|
+
if (!ask || ask.kind !== 'ask' || !me.agent || !me.projectId || thread?.space_id !== spaceId()
|
|
334
|
+
|| thread.project_id !== me.projectId) {
|
|
335
|
+
throw validation('No such message for this Agent in the current project');
|
|
336
|
+
}
|
|
337
|
+
return ask;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function cancelAsk(ask) {
|
|
341
|
+
if (app.peer?.hasSent(ask.id)) {
|
|
342
|
+
if (!REMOTE_PENDING.has(ask.status)) return remoteView(ask);
|
|
343
|
+
return remoteView(applyRemote(ask.id, await app.peer.cancel(ask.id)));
|
|
344
|
+
}
|
|
345
|
+
if (ask.status === 'queued') store.updateMessage(ask.id, { status: 'cancelled', error: 'cancelled' });
|
|
346
|
+
else if (ask.status === 'delivering') throw validation('This message is being answered in another process; it ends at its timeout');
|
|
347
|
+
return statusOf(store.getMessage(ask.id));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// The local owner can inspect and cancel any message from the console.
|
|
351
|
+
function ownedAsk(messageId) {
|
|
352
|
+
const ask = store.getMessage(messageId);
|
|
353
|
+
const thread = ask && store.getThread(ask.thread_id);
|
|
354
|
+
if (!ask || ask.kind !== 'ask' || thread?.space_id !== spaceId()) throw validation('Message not found');
|
|
355
|
+
return ask;
|
|
163
356
|
}
|
|
164
357
|
|
|
165
358
|
async function readMessages({ taskContextToken, sourceApplication }) {
|
|
@@ -174,6 +367,7 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
|
|
|
174
367
|
const ask = store.getMessage(messageId);
|
|
175
368
|
if (!ask || ask.kind !== 'ask' || !me.agent || ask.to_agent !== me.agent.agentId) throw validation('No such message for this Agent');
|
|
176
369
|
if (ask.status === 'answered') throw validation('This message was already answered');
|
|
370
|
+
if (ask.from_agent?.startsWith('peer:')) throw validation('A remote device message is answered by its delivery, not from the inbox');
|
|
177
371
|
store.updateMessage(ask.id, { status: 'answered', via: `${me.client}:inbox` });
|
|
178
372
|
store.appendMessage(ask.thread_id, { id: idFactory(), kind: 'reply', body, status: 'sent', inReplyTo: ask.id,
|
|
179
373
|
via: `${me.client}:inbox`, from: { agentId: me.agent.agentId, name: label(me.agent), client: me.client, session: me.session },
|
|
@@ -181,6 +375,29 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
|
|
|
181
375
|
return { status: 'sent', threadId: ask.thread_id };
|
|
182
376
|
}
|
|
183
377
|
|
|
378
|
+
function refreshRemote({ threadId = null } = {}) {
|
|
379
|
+
if (!app.peer) return Promise.resolve();
|
|
380
|
+
const key = threadId ?? 'directory';
|
|
381
|
+
if (refreshes.has(key)) return refreshes.get(key);
|
|
382
|
+
const records = threadId ? [store.getThread(threadId)] : store.listThreads(spaceId(), { limit: 200 });
|
|
383
|
+
const pending = records.filter((record) => record?.space_id === spaceId()).flatMap((record) => store.messages(record.id))
|
|
384
|
+
.filter((message) => message.kind === 'ask' && REMOTE_PENDING.has(message.status) && app.peer.hasSent(message.id));
|
|
385
|
+
const start = pending.length && !threadId ? refreshOffset % pending.length : 0;
|
|
386
|
+
const batch = [...pending.slice(start), ...pending.slice(0, start)].slice(0, 20);
|
|
387
|
+
if (!threadId) refreshOffset += batch.length;
|
|
388
|
+
// A shared deadline bounds the whole console refresh even with many offline asks.
|
|
389
|
+
const signal = AbortSignal.timeout(2_000);
|
|
390
|
+
const work = Promise.all(Array.from({ length: Math.min(4, batch.length) }, async () => {
|
|
391
|
+
while (batch.length && !signal.aborted) {
|
|
392
|
+
const message = batch.shift();
|
|
393
|
+
try { applyRemote(message.id, await app.peer.status(message.id, { signal, timeoutMs: 2_000 })); }
|
|
394
|
+
catch { /* Keep the last known result when the other computer is unavailable. */ }
|
|
395
|
+
}
|
|
396
|
+
})).finally(() => refreshes.delete(key));
|
|
397
|
+
refreshes.set(key, work);
|
|
398
|
+
return work;
|
|
399
|
+
}
|
|
400
|
+
|
|
184
401
|
function threads({ limit = 50 } = {}) {
|
|
185
402
|
return { threads: store.listThreads(spaceId(), { limit }).map((thread) => {
|
|
186
403
|
const messages = store.messages(thread.id);
|
|
@@ -194,7 +411,7 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
|
|
|
194
411
|
const last = messages.at(-1);
|
|
195
412
|
return { id: thread.id, subject: thread.subject, projectId: thread.project_id, updatedAt: thread.updated_at,
|
|
196
413
|
messageCount: messages.length, participants: [...people.values()],
|
|
197
|
-
waiting: messages.some((message) => message.kind === 'ask' &&
|
|
414
|
+
waiting: messages.some((message) => message.kind === 'ask' && !['answered', 'cancelled', 'failed', 'expired', 'unknown'].includes(message.status)),
|
|
198
415
|
last: last ? { from: last.from_name, body: last.body.slice(0, 160) } : null };
|
|
199
416
|
}) };
|
|
200
417
|
}
|
|
@@ -216,11 +433,23 @@ export function createAgentRoundtable({ app, openStore, wake = wakeAgent, idFact
|
|
|
216
433
|
? { token: env.FULI_ROUNDTABLE_DELEGATION, sessionId: sessionId ?? null } : null;
|
|
217
434
|
}
|
|
218
435
|
|
|
219
|
-
return { findAgents, messageAgent, readMessages, replyMessage, threads, thread, taskEntryDelegation,
|
|
436
|
+
return { findAgents, messageAgent, readMessages, replyMessage, threads, thread, refreshRemote, taskEntryDelegation,
|
|
437
|
+
messageStatus, cancelMessage, answerRemote,
|
|
438
|
+
ownerMessageStatus: ({ messageId }) => statusOf(ownedAsk(messageId)),
|
|
439
|
+
ownerCancelMessage: ({ messageId }) => cancelAsk(ownedAsk(messageId)),
|
|
220
440
|
pending: (agentId) => store.pendingFor(spaceId(), agentId).map(inboxItem),
|
|
221
441
|
close: () => opened?.close() };
|
|
222
442
|
}
|
|
223
443
|
|
|
444
|
+
function remoteRowId(kind, parts) {
|
|
445
|
+
return `peer-${kind}-${createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 40)}`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function peerDepth(env) {
|
|
449
|
+
const depth = Number(env.FULI_ROUNDTABLE_PEER_DEPTH);
|
|
450
|
+
return Number.isSafeInteger(depth) && depth > 0 ? depth : 0;
|
|
451
|
+
}
|
|
452
|
+
|
|
224
453
|
function queued(thread, ask, target, reason) {
|
|
225
454
|
return { status: 'queued', threadId: thread.id, messageId: ask.id, reason,
|
|
226
455
|
recipient: { agentId: target.agentId, name: label(target) },
|
|
@@ -13,7 +13,7 @@ export const ROUNDTABLE_TOOL_DEFINITIONS = [
|
|
|
13
13
|
{
|
|
14
14
|
name: 'message_agent',
|
|
15
15
|
title: 'ROUNDTABLE · Ask another Agent',
|
|
16
|
-
description: 'Send a message to another Agent, like asking a teammate or a sub-agent. By default Fuli wakes the recipient in its own client (resuming its latest conversation, or one from find_agents) read-only, and returns its answer in reply. If it cannot be woken the message waits in its inbox. Pass the current taskContextToken so the recipient knows who is asking, projectPath as the current working directory, and threadId to continue a thread. Every exchange is recorded for the user.',
|
|
16
|
+
description: 'Send a message to another Agent, like asking a teammate or a sub-agent. By default Fuli wakes the recipient in its own client (resuming its latest conversation, or one from find_agents) read-only, and returns its answer in reply. If it cannot be woken the message waits in its inbox. Pass the current taskContextToken so the recipient knows who is asking, projectPath as the current working directory, and threadId to continue a thread. A peer:<device>:<binding> address from find_agents asks the shared project lead on another paired device (LAN roundtable Beta, project leads only). Every exchange is recorded for the user.',
|
|
17
17
|
inputSchema: objectSchema({
|
|
18
18
|
taskContextToken: text(160),
|
|
19
19
|
to: text(160),
|
|
@@ -43,6 +43,18 @@ export const ROUNDTABLE_TOOL_DEFINITIONS = [
|
|
|
43
43
|
title: 'ROUNDTABLE · Read a thread',
|
|
44
44
|
description: 'Read every message of one Agent conversation thread.',
|
|
45
45
|
inputSchema: objectSchema({ threadId: text(128) }, ['threadId'])
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'get_agent_message_status',
|
|
49
|
+
title: 'ROUNDTABLE · Check a message',
|
|
50
|
+
description: 'Check whether a message you sent or received in the current task\'s project was answered, including asks to a shared project lead on another paired device (LAN roundtable Beta). "unknown" means the receiving device lost track of the attempt; it may or may not have run and is not retried.',
|
|
51
|
+
inputSchema: objectSchema({ taskContextToken: text(160), messageId: text(128) }, ['taskContextToken', 'messageId'])
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: 'cancel_agent_message',
|
|
55
|
+
title: 'ROUNDTABLE · Cancel a message',
|
|
56
|
+
description: 'Cancel a message the current task\'s Agent sent in this project. A waiting message is withdrawn; one already running on another device is asked to stop.',
|
|
57
|
+
inputSchema: objectSchema({ taskContextToken: text(160), messageId: text(128) }, ['taskContextToken', 'messageId'])
|
|
46
58
|
}
|
|
47
59
|
];
|
|
48
60
|
|
|
@@ -56,6 +68,8 @@ export function callRoundtableTool(roundtable, name, input) {
|
|
|
56
68
|
case 'read_agent_messages': return roundtable.readMessages(input);
|
|
57
69
|
case 'reply_agent_message': return roundtable.replyMessage(input);
|
|
58
70
|
case 'read_agent_thread': return roundtable.thread(input);
|
|
71
|
+
case 'get_agent_message_status': return roundtable.messageStatus(input);
|
|
72
|
+
case 'cancel_agent_message': return roundtable.cancelMessage(input);
|
|
59
73
|
default: throw new TypeError(`Unknown roundtable tool: ${name}`);
|
|
60
74
|
}
|
|
61
75
|
}
|
|
@@ -33,12 +33,14 @@ export function sessionWorkingDirectory(client, sessionId, { home = homedir(), e
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
export async function wakeAgent({ client, sessionId = null, cwd, prompt, timeoutMs = 300_000,
|
|
36
|
-
delegationToken = null, env = process.env, run = runProcess } = {}) {
|
|
36
|
+
delegationToken = null, peerDepth = null, env = process.env, run = runProcess, signal, onSpawn } = {}) {
|
|
37
37
|
const command = resolveClientCommand(client, { env });
|
|
38
38
|
if (!command) throw Object.assign(new Error(`${client} is not installed on this machine`), { code: 'client_unavailable' });
|
|
39
39
|
const childEnv = Object.fromEntries(Object.entries(env).filter(([key]) => !key.startsWith('FULI_ROUNDTABLE_')));
|
|
40
40
|
if (delegationToken) childEnv.FULI_ROUNDTABLE_DELEGATION = delegationToken;
|
|
41
|
-
|
|
41
|
+
if (peerDepth) childEnv.FULI_ROUNDTABLE_PEER_DEPTH = String(peerDepth);
|
|
42
|
+
const result = await run(command, wakeArguments(client, { sessionId, cwd }),
|
|
43
|
+
{ cwd, env: childEnv, input: prompt, timeoutMs, signal, onSpawn });
|
|
42
44
|
return parseFinalAnswer(client, result);
|
|
43
45
|
}
|
|
44
46
|
|
|
@@ -62,10 +64,17 @@ export function parseFinalAnswer(client, { code, stdout }) {
|
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
// Owns exactly one child process tree and never touches unrelated clients.
|
|
65
|
-
export function runProcess(command, args, { cwd, env, input, timeoutMs, maxOutputBytes = 4_000_000 }) {
|
|
67
|
+
export function runProcess(command, args, { cwd, env, input, timeoutMs, maxOutputBytes = 4_000_000, signal, onSpawn }) {
|
|
66
68
|
return new Promise((resolve, reject) => {
|
|
69
|
+
if (signal?.aborted) {
|
|
70
|
+
reject(Object.assign(new Error('The delivery was cancelled before it started'), { code: 'cancelled', started: false }));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
67
73
|
const child = spawn(command, args, { cwd, env, shell: false, windowsHide: true,
|
|
68
74
|
detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'ignore'] });
|
|
75
|
+
child.once('spawn', () => onSpawn?.());
|
|
76
|
+
signal?.addEventListener('abort', () => finish(Object.assign(new Error('The delivery was cancelled'),
|
|
77
|
+
{ code: 'cancelled', reason: String(signal.reason ?? 'cancelled') })), { once: true });
|
|
69
78
|
let output = '', bytes = 0, settled = false;
|
|
70
79
|
const kill = () => {
|
|
71
80
|
if (!child.pid) return;
|
|
@@ -764,7 +764,7 @@ export const GRAPH_TOOL_DEFINITIONS = [
|
|
|
764
764
|
{
|
|
765
765
|
name: 'coordinate_project_agent_task',
|
|
766
766
|
title: 'WRITE · Prepare a host-executed Agent team',
|
|
767
|
-
description: 'Resolve the exact local project, or create an isolated temporary task scope when no project matches, then let the Provider coordinator route one durable task and assemble an isolated context bundle for every selected lead/collaborator. Ambiguous matches require exact selection. Pass the current taskContextToken so temporary scope and a newly recruited lead bind to the owned lifecycle; otherwise provide a stable sourceSessionId and idempotencyKey for retries. Temporary projects retain audit history, never become implicit directory bindings, and default to audited task-only recruitment under the existing
|
|
767
|
+
description: 'Resolve the exact local project, or create an isolated temporary task scope when no project matches, then let the Provider coordinator route one durable task and assemble an isolated context bundle for every selected lead/collaborator. Ambiguous matches require exact selection. Pass the current taskContextToken so temporary scope and a newly recruited lead bind to the owned lifecycle; otherwise provide a stable sourceSessionId and idempotencyKey for retries. Temporary projects retain audit history, never become implicit directory bindings, and default to audited task-only recruitment under the existing AR policy; explicit durable recruitment requests remain explicit. When recruiting, AR should set recruitmentProfile.character from the role responsibilities and preserve user expectations; configured traits must never be presented as proven growth. Fuli persists identity, staffing, and memory scope; the host must start real workers, report their concrete evidence, and finish their lifecycle. A routing recommendation never proves that a worker started.',
|
|
768
768
|
inputSchema: projectAgentTaskCoordinateInput
|
|
769
769
|
},
|
|
770
770
|
{
|
|
@@ -792,7 +792,7 @@ export const GRAPH_TOOL_DEFINITIONS = [
|
|
|
792
792
|
{
|
|
793
793
|
name: 'submit_project_agent_task',
|
|
794
794
|
title: 'WRITE · Submit a routed Agent task',
|
|
795
|
-
description: 'Submit one durable task to the FULI control plane. Every new task requires a passed quality gate for its exact artifact revision before completion; use record_agent_verification with actual run evidence. Existing matching durable Agents are preferred; no anonymous temporary Agent is created. The Agent locked executor policy outranks task, assignment, project, and space rules; explicit user priority is never lowered for token savings. The space coordinator, complexity, model strategy, routing decision, boundaries, actual executor/model/rule/fallback audit fields, and any
|
|
795
|
+
description: 'Submit one durable task to the FULI control plane. Every new task requires a passed quality gate for its exact artifact revision before completion; use record_agent_verification with actual run evidence. Existing matching durable Agents are preferred; no anonymous temporary Agent is created. The Agent locked executor policy outranks task, assignment, project, and space rules; explicit user priority is never lowered for token savings. The space coordinator, complexity, model strategy, routing decision, boundaries, actual executor/model/rule/fallback audit fields, and any AR disclosure are persisted by the Provider. AR should fill recruitmentProfile.character from role responsibilities, preserve user expectations, and distinguish configured traits from demonstrated growth; never invent past achievements.',
|
|
796
796
|
inputSchema: projectAgentTaskSubmitInput
|
|
797
797
|
},
|
|
798
798
|
{
|
|
@@ -833,10 +833,19 @@ export const GRAPH_TOOL_DEFINITIONS = [
|
|
|
833
833
|
personalProjectId: id
|
|
834
834
|
}, ['personalSpaceId', 'personalProjectId'])
|
|
835
835
|
},
|
|
836
|
+
{
|
|
837
|
+
name: 'get_project_team',
|
|
838
|
+
title: 'READ · Get project team',
|
|
839
|
+
description: 'Read the exact project people view: the one accountable lead, explicit team members reporting to that lead, other Agents with an active assignment as collaborators, and assigned peer roles such as AR or project managers. Ended and archived assignments are excluded; status no_lead or lead_unavailable is reported truthfully. Never includes private memory and never resolves a parent or child project.',
|
|
840
|
+
inputSchema: objectSchema({
|
|
841
|
+
personalSpaceId: id,
|
|
842
|
+
personalProjectId: id
|
|
843
|
+
}, ['personalSpaceId', 'personalProjectId'])
|
|
844
|
+
},
|
|
836
845
|
{
|
|
837
846
|
name: 'update_project_agent_coordination_policy',
|
|
838
847
|
title: 'WRITE · Set project Agent continuity policy',
|
|
839
|
-
description: 'Persist project collaboration switches and an optional stable team lead with members.
|
|
848
|
+
description: 'Persist project collaboration switches and an optional stable team lead with members. AR and employee project managers remain peer roles and cannot be team members. Omitted team fields preserve the current team. Read policy first and send expectedUpdatedAt to prevent overwriting concurrent edits. This changes policy only and never starts a worker.',
|
|
840
849
|
inputSchema: objectSchema({
|
|
841
850
|
personalSpaceId: id,
|
|
842
851
|
personalProjectId: id,
|
|
@@ -869,7 +878,7 @@ export const GRAPH_TOOL_DEFINITIONS = [
|
|
|
869
878
|
{
|
|
870
879
|
name: 'list_project_agent_recruitments',
|
|
871
880
|
title: 'READ · List recruitment records',
|
|
872
|
-
description: 'List auditable
|
|
881
|
+
description: 'List auditable AR recruitment records with trigger client/session, role, capabilities, position kind, reason, status, and created/recruited Agent.',
|
|
873
882
|
inputSchema: objectSchema({
|
|
874
883
|
personalSpaceId: id,
|
|
875
884
|
personalProjectId: nullableStringSchema(),
|
|
@@ -880,7 +889,7 @@ export const GRAPH_TOOL_DEFINITIONS = [
|
|
|
880
889
|
{
|
|
881
890
|
name: 'decide_project_agent_recruitment',
|
|
882
891
|
title: 'WRITE · Approve or cancel recruitment',
|
|
883
|
-
description: 'Approve or cancel one pending
|
|
892
|
+
description: 'Approve or cancel one pending AR recruitment event with an expected revision. Approval creates only the declared durable or bounded temporary role through the Provider.',
|
|
884
893
|
inputSchema: objectSchema({
|
|
885
894
|
personalSpaceId: id,
|
|
886
895
|
personalProjectId: id,
|
|
@@ -154,6 +154,7 @@ const HANDLERS = Object.freeze({
|
|
|
154
154
|
view_project_agent_activity: (app, input) => app.viewProjectAgentActivity(input),
|
|
155
155
|
get_project_agent_coordination_policy: (app, input) =>
|
|
156
156
|
app.getProjectAgentCoordinationPolicy(input),
|
|
157
|
+
get_project_team: (app, input) => app.getProjectTeam(input),
|
|
157
158
|
update_project_agent_coordination_policy: (app, input) =>
|
|
158
159
|
app.updateProjectAgentCoordinationPolicy(input),
|
|
159
160
|
get_project_agent_recruitment_policy: (app, input) =>
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
const UI_MUTATION_PARITY = Object.freeze([
|
|
2
2
|
agent('PUT /api/agent-pins', 'pin_agent'),
|
|
3
|
+
agent('POST /api/roundtable/messages/:messageId/cancel', 'cancel_agent_message'),
|
|
4
|
+
local('POST /api/peer/coordinator', 'Opening a LAN listener on this device is a local device decision of its owner.'),
|
|
5
|
+
local('POST /api/peer/invitations', 'Pairing invitations admit another device and are created by the local owner.'),
|
|
6
|
+
local('POST /api/peer/invitations/preview', 'The local owner inspects a private device invitation before deciding to pair.'),
|
|
7
|
+
local('POST /api/peer/join', 'Pairing this device with a coordinator is a local owner decision.'),
|
|
8
|
+
local('PUT /api/peer/shares', 'Which project leads, clients and folders other devices may use is chosen by the local owner.'),
|
|
9
|
+
local('POST /api/peer/devices/:nodeId/revoke', 'Revoking a paired device is a local owner decision.'),
|
|
10
|
+
local('POST /api/peer/disable', 'Leaving the LAN roundtable is a local owner decision.'),
|
|
3
11
|
local('PUT /api/judgment/policy', 'Autonomy grants delegated authority and requires the local user.'),
|
|
4
12
|
local('POST /api/judgment/feedback', 'Votes and reasons are human feedback, never model-generated approval.'),
|
|
5
13
|
agent('POST /api/judgment/review', 'review_with_tonborg'),
|
|
@@ -88,7 +96,8 @@ export function agentInterfaceCatalog() {
|
|
|
88
96
|
{ domain: 'human-attention', toolNames: ['list_agent_attention', 'request_agent_attention', 'cancel_agent_attention'], guidance: 'Raise a hand only for an explicit human action. Read resolved responses before continuing. Responses do not grant permissions or accept tasks.' },
|
|
89
97
|
{ domain: 'projects', toolNames: ['list_knowledge_spaces', 'list_personal_projects', 'list_project_releases', 'list_project_relations'] },
|
|
90
98
|
{ domain: 'knowledge', toolNames: ['search_knowledge_graph', 'get_knowledge_graph', 'get_user_taste_skill', 'list_preference_conflicts'] },
|
|
91
|
-
{ domain: 'people-and-tasks', toolNames: ['list_project_agents', 'list_project_agent_assignments', 'list_project_agent_tasks', 'view_project_agent_task', 'view_project_agent_activity', 'list_project_agent_recruitments'] },
|
|
99
|
+
{ domain: 'people-and-tasks', toolNames: ['get_project_team', 'list_project_agents', 'list_project_agent_assignments', 'list_project_agent_tasks', 'view_project_agent_task', 'view_project_agent_activity', 'list_project_agent_recruitments'] },
|
|
100
|
+
{ domain: 'roundtable', toolNames: ['find_agents', 'read_agent_messages', 'read_agent_thread', 'get_agent_message_status'], readRoutes: ['GET /api/roundtable/threads', 'GET /api/roundtable/threads/:threadId', 'GET /api/roundtable/messages/:messageId'], guidance: 'message_agent and reply_agent_message write recorded thread messages as the current task Agent. A queued message is not a reply, and a reply is not evidence that work ran. Remote asks reach only a shared project lead on a paired device; unknown means it may or may not have run. Pairing and sharing devices stay with the local owner. When task entry returns requested_agent_id, this session is still the selected Agent; follow requested_agent_guidance instead of acting as the requested Agent.' },
|
|
92
101
|
{ domain: 'employees-and-boards', toolNames: ['list_employee_templates', 'list_employee_tools', 'call_employee_tool'], guidance: 'Discover the employee read_board and other read tools before invoking them with exact project scope.' },
|
|
93
102
|
{ domain: 'external-knowledge', toolNames: ['list_external_knowledge_connectors', 'discover_external_knowledge_sources', 'list_external_knowledge_bindings', 'retrieve_external_knowledge_binding', 'get_external_knowledge_conflict_policy'], readRoutes: ['POST /api/external-knowledge/discover', 'POST /api/external-knowledge/bindings/:bindingId/retrieve'] },
|
|
94
103
|
{ domain: 'executors', toolNames: ['list_executors', 'get_executor', 'list_executor_routing_rules', 'list_project_agent_routing_learning'] },
|
|
@@ -61,7 +61,7 @@ export const projectAgentProfile = objectSchema({
|
|
|
61
61
|
taste: { type: 'string', maxLength: 2048, default: '' },
|
|
62
62
|
personality: { type: 'string', maxLength: 2048, default: '' }
|
|
63
63
|
}),
|
|
64
|
-
description: '
|
|
64
|
+
description: 'AR-configured judgment, taste and personality based on role responsibilities. These are intended working traits, not evidence of observed growth or performance.'
|
|
65
65
|
},
|
|
66
66
|
expectations: {
|
|
67
67
|
type: 'string', maxLength: 4096, default: '',
|
|
@@ -53,7 +53,7 @@ function unassignedReceipt(status, reason) {
|
|
|
53
53
|
agent_selection_conflict: '分配待确认(FLA 选择冲突)',
|
|
54
54
|
manual_selection: '分配待确认(需要选择负责人)'
|
|
55
55
|
};
|
|
56
|
-
return `FULI Agent:${labels[status] ?? '分配未完成(
|
|
56
|
+
return `FULI Agent:${labels[status] ?? '分配未完成(AR 尚未返回负责人)'}`;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export function employeeCollaborationReceipt(application, context, { tool, permission, sourceApplication }) {
|
|
@@ -19,8 +19,11 @@ Local service:
|
|
|
19
19
|
open [--data-dir DIR]
|
|
20
20
|
|
|
21
21
|
Install and Agent connection:
|
|
22
|
+
connect [--format standard|vscode|server] [--data-dir DIR | --runtime-config FILE] (print generic MCP configuration)
|
|
23
|
+
mcp [--runtime-config FILE] [--source-application codex|claude|claude_code|cursor|kiro|other] (stdio server)
|
|
22
24
|
setup [--yes] [--codex-only] [--data-dir DIR] [--personal-space NAME] [--port PORT] [--runtime-mode container|native] [--memory-profile low|balanced] [--adaptive-memory|--no-adaptive-memory] [--skip-agents] [--no-start] [--personal-only|--with-dev-public]
|
|
23
25
|
connect-workspace --url URL --token-file FILE [--data-dir DIR]
|
|
26
|
+
peer status|host|invite|join|share|revoke|off [--data-dir DIR] (LAN roundtable Beta: pair devices and share project leads)
|
|
24
27
|
remote-mcp --personal-project-id ID --bearer-token-file FILE [--port PORT] [--data-dir DIR] [--source-application claude|cursor|other] [--allowed-origin HTTPS_ORIGIN] [--allowed-host HOST] [--max-sessions N] [--session-idle-ttl-seconds N]
|
|
25
28
|
employee install BUILT_PACKAGE_DIRECTORY [--replace] [--runtime-config FILE]
|
|
26
29
|
update [setup options]
|