@dotdrelle/wiki-manager 0.15.96 → 0.15.98
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/package.json +2 -2
- package/src/agent/graph.js +62 -5
- package/src/agent/graph.test.js +113 -1
- package/src/cli/wiki-manager.js +47 -33
- package/src/commands/slash.js +7 -2
- package/src/core/agentEvents.js +31 -1
- package/src/core/agentEvents.test.js +59 -0
- package/src/core/buildInfo.json +2 -2
- package/src/core/mcp.js +1 -1
- package/src/core/testGate.test.js +33 -0
- package/src/core/toolLoop.js +7 -1
- package/src/runtime/controlClassify.test.js +31 -0
- package/src/runtime/runner.js +13 -4
- package/src/runtime/runner.test.js +20 -0
- package/src/runtime/server.js +173 -35
- package/src/runtime/server.test.js +145 -14
- package/src/runtime/skillRun.js +2 -2
- package/src/runtime/skillRun.test.js +5 -3
- package/src/shell/openExternal.js +43 -0
- package/src/shell/repl.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dotdrelle/wiki-manager",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.98",
|
|
4
4
|
"description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
},
|
|
20
20
|
"scripts": {
|
|
21
21
|
"start": "bun ./bin/wiki-manager.js",
|
|
22
|
-
"test": "node --test src/core/skillInvocation.test.js src/core/skillCompiler.test.js src/runtime/skillRun.test.js src/runtime/controlDrain.test.js src/runtime/controlCancellation.test.js src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/agent/skillRecursion.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/workspaceProfile.test.js src/core/buildInfo.test.js src/core/agentEvents.test.js src/core/skillChainView.test.js src/core/runtimeLog.test.js src/core/runtimeEventAdapter.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/otherWorkspacesRunning.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikiWorkspaceStart.test.js src/core/wikirc.test.js src/core/workspaceInherit.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/currentArtifact.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/orchestrator/providers/fakeRuntimeProvider.test.js src/orchestrator/providers/runtimeProviders.test.js src/orchestrator/providers/dispatcherExternalRuntime.test.js src/orchestrator/providers/deepAgentsProvider.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardModality.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/workspaceIsolation.test.js src/runtime/controlMessages.test.js src/runtime/deltaCoalescer.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/skillChain.e2e.test.js src/runtime/donna-contract.test.js src/runtime/approvals.test.js src/runtime/auth.test.js src/runtime/totp.test.js src/runtime/loginSession.test.js src/runtime/loginRoutes.test.js",
|
|
22
|
+
"test": "node --test src/core/skillInvocation.test.js src/core/skillCompiler.test.js src/runtime/skillRun.test.js src/runtime/controlDrain.test.js src/runtime/controlCancellation.test.js src/cli/runtimeStartup.test.js src/cli/wiki-manager.test.js src/agent/graph.test.js src/agent/skillRecursion.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/env.test.js src/core/agentsCompose.test.js src/core/profileServiceStatus.test.js src/core/workspaceProfile.test.js src/core/buildInfo.test.js src/core/testGate.test.js src/core/agentEvents.test.js src/core/skillChainView.test.js src/core/runtimeLog.test.js src/core/runtimeEventAdapter.test.js src/activity/activityAggregator.test.js src/graph/runGraphProjector.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/mcpEndpoints.test.js src/core/toolLoop.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/otherWorkspacesRunning.test.js src/core/wikiSetup.test.js src/core/wikiWorkspace.test.js src/core/wikiWorkspaceStart.test.js src/core/wikirc.test.js src/core/workspaceInherit.test.js src/core/cacert.test.js src/core/composeOverrides.test.js src/core/setEnvValue.test.js src/core/commandFailure.test.js src/core/currentArtifact.test.js src/core/googleGrants.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/orchestrator/agentRegistry.test.js src/orchestrator/capabilityRegistry.test.js src/orchestrator/capabilityResolver.test.js src/orchestrator/planValidator.test.js src/orchestrator/planIntegrator.test.js src/orchestrator/taskStatuses.test.js src/orchestrator/scheduler.test.js src/orchestrator/attemptManager.test.js src/orchestrator/resultAggregator.test.js src/orchestrator/approvalPolicy.test.js src/orchestrator/dispatcher.test.js src/orchestrator/objectiveResolver.test.js src/orchestrator/providers/fakeRuntimeProvider.test.js src/orchestrator/providers/runtimeProviders.test.js src/orchestrator/providers/dispatcherExternalRuntime.test.js src/orchestrator/providers/deepAgentsProvider.test.js src/commands/slash.test.js src/shell/repl.test.js src/shell/setupWizardModality.test.js src/shell/setupWizardPlaceholders.test.js src/shell/setupWizardSuggestions.test.js src/shell/setupWizardDiscovery.test.js src/shell/wrapText.test.js src/runtime/lifecycle.test.js src/runtime/store.test.js src/runtime/workspaceIsolation.test.js src/runtime/controlMessages.test.js src/runtime/controlClassify.test.js src/runtime/deltaCoalescer.test.js src/runtime/recoveryManager.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/delegation.test.js src/runtime/runner.test.js src/runtime/runner.e2e.test.js src/runtime/skillChain.e2e.test.js src/runtime/donna-contract.test.js src/runtime/approvals.test.js src/runtime/auth.test.js src/runtime/totp.test.js src/runtime/loginSession.test.js src/runtime/loginRoutes.test.js",
|
|
23
23
|
"check-versions": "node scripts/check-versions.js",
|
|
24
24
|
"prepack": "node scripts/check-versions.js",
|
|
25
25
|
"prepublishOnly": "node scripts/check-versions.js",
|
package/src/agent/graph.js
CHANGED
|
@@ -436,6 +436,19 @@ export function bareToolCallJson(content, tools = []) {
|
|
|
436
436
|
return hasArguments ? name : null;
|
|
437
437
|
}
|
|
438
438
|
|
|
439
|
+
/**
|
|
440
|
+
* A model that has NO tool to call sometimes writes the call as text:
|
|
441
|
+
* `runtime__delegate{"objective":"…"}`. Unlike `bareToolCallJson` it is not
|
|
442
|
+
* JSON and cannot be validated against the offered set (there is none), but the
|
|
443
|
+
* `<namespace>__<tool>{` shape is never legitimate prose. Observed after a
|
|
444
|
+
* terminal failure stripped the tools from the synthesis turn: the raw call
|
|
445
|
+
* reached the user and nothing ran.
|
|
446
|
+
*/
|
|
447
|
+
export function narratedToolCallText(content) {
|
|
448
|
+
const match = String(content ?? '').trim().match(/^([a-z][a-z0-9_-]*__[a-z][a-z0-9_-]*)\s*\{/i);
|
|
449
|
+
return match ? match[1] : null;
|
|
450
|
+
}
|
|
451
|
+
|
|
439
452
|
function parseActionJson(text) {
|
|
440
453
|
const cleaned = String(text ?? '').trim().replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '');
|
|
441
454
|
if (!cleaned) return null;
|
|
@@ -979,7 +992,7 @@ export async function handleRuntimeControlTool(session, tool, args = {}) {
|
|
|
979
992
|
}
|
|
980
993
|
if (tool === 'run_skill') {
|
|
981
994
|
const skillName = String(args.skillName ?? '').trim();
|
|
982
|
-
if (!skillName) return JSON.stringify({ ok: false, terminal:
|
|
995
|
+
if (!skillName) return JSON.stringify({ ok: false, terminal: false, code: 'skill_not_found', message: 'skillName is required: pass the exact skill name, or delegate the objective with runtime__delegate.', availableSkills: [] });
|
|
983
996
|
const selectedSkill = findSkill(session, skillName);
|
|
984
997
|
const suppliedArguments = args.arguments && typeof args.arguments === 'object' && !Array.isArray(args.arguments)
|
|
985
998
|
? args.arguments
|
|
@@ -1696,10 +1709,14 @@ export function createAgentGraph(options = {}) {
|
|
|
1696
1709
|
// text really is a call to one of them: a legitimate answer that happens
|
|
1697
1710
|
// to contain JSON (a config excerpt, an API sample) must go through
|
|
1698
1711
|
// untouched, which is why this is not a "content starts with {" test.
|
|
1699
|
-
const bareCall = tools.length > 0 ? bareToolCallJson(result.content, tools) : null
|
|
1712
|
+
const bareCall = (tools.length > 0 ? bareToolCallJson(result.content, tools) : null)
|
|
1713
|
+
?? narratedToolCallText(result.content);
|
|
1700
1714
|
if (bareCall) {
|
|
1701
1715
|
const retries = Number(state.invalidToolCallRetries ?? 0);
|
|
1702
|
-
|
|
1716
|
+
// Retry only when a tool can still be called; when none are offered (the
|
|
1717
|
+
// synthesis turn after a terminal failure) the call is unexecutable and
|
|
1718
|
+
// the honest failure below is the whole answer.
|
|
1719
|
+
if (tools.length > 0 && retries < 2) {
|
|
1703
1720
|
state.session._onStreamReset?.();
|
|
1704
1721
|
state.session._onStep?.('Agent: tool call written as JSON text rejected; retrying…');
|
|
1705
1722
|
return {
|
|
@@ -2014,6 +2031,18 @@ export function createAgentGraph(options = {}) {
|
|
|
2014
2031
|
),
|
|
2015
2032
|
objectives: Number(skillResult.objectiveCount ?? skillResult.objectives ?? 1) || 1,
|
|
2016
2033
|
};
|
|
2034
|
+
} else if (skillResult?.ok === false && skillResult?.needsInput !== true) {
|
|
2035
|
+
// A recoverable refusal (a guessed skill that does not exist):
|
|
2036
|
+
// keep the turn alive so the model can correct itself or
|
|
2037
|
+
// delegate, but do not let the progress note call it a success.
|
|
2038
|
+
//
|
|
2039
|
+
// `needsInput` is excluded on purpose: "ask the user for the
|
|
2040
|
+
// missing scope" is a conversational blocker, and marking it
|
|
2041
|
+
// failed published `runtime__run_skill failed: {…}` into the
|
|
2042
|
+
// progress surfaces while the turn was working exactly as
|
|
2043
|
+
// intended. The `delegate` branch below already treats the
|
|
2044
|
+
// identical case that way; the two must not disagree.
|
|
2045
|
+
ok = false;
|
|
2017
2046
|
}
|
|
2018
2047
|
}
|
|
2019
2048
|
if (tool === 'delegate' && /^Runtime control error \(delegate\):/i.test(resultText)) {
|
|
@@ -2164,7 +2193,35 @@ export function createAgentGraph(options = {}) {
|
|
|
2164
2193
|
tool_call_id: call.id,
|
|
2165
2194
|
content: boundedResult,
|
|
2166
2195
|
});
|
|
2167
|
-
if (terminalFailure) break;
|
|
2196
|
+
if (terminalFailure || skillLaunch) break;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
// A skill launch owns execution and ends the turn, so the rest of the
|
|
2200
|
+
// batch is NOT executed — a companion runtime__delegate in the same batch
|
|
2201
|
+
// used to start a second, independent run with nothing in the thread
|
|
2202
|
+
// naming it. Every unexecuted call still gets its tool result: a provider
|
|
2203
|
+
// that sees tool_calls without matching results on a replayed history
|
|
2204
|
+
// rejects the conversation.
|
|
2205
|
+
if (skillLaunch) {
|
|
2206
|
+
const executed = new Set(toolResultMessages.map((message) => message.tool_call_id));
|
|
2207
|
+
const dropped = toolCalls.filter((call) => !executed.has(call.id));
|
|
2208
|
+
const notRun = `Not executed: ${skillLaunch.publicInput} was launched earlier in this `
|
|
2209
|
+
+ 'turn and owns execution from here. Do not start a second run for the same objective.';
|
|
2210
|
+
for (const call of dropped) {
|
|
2211
|
+
emitAgentEvent(state.session, 'tool_call_result', 'tool', {
|
|
2212
|
+
callId: call.id,
|
|
2213
|
+
name: call.function?.name ?? 'tool',
|
|
2214
|
+
ok: false,
|
|
2215
|
+
result: notRun,
|
|
2216
|
+
summary: 'skipped',
|
|
2217
|
+
});
|
|
2218
|
+
toolResultMessages.push({ role: 'tool', tool_call_id: call.id, content: notRun });
|
|
2219
|
+
}
|
|
2220
|
+
if (dropped.length > 0) {
|
|
2221
|
+
state.session._onStep?.(
|
|
2222
|
+
`${dropped.length} tool call(s) skipped: ${skillLaunch.publicInput} owns execution`,
|
|
2223
|
+
);
|
|
2224
|
+
}
|
|
2168
2225
|
}
|
|
2169
2226
|
|
|
2170
2227
|
if (terminalFailure) {
|
|
@@ -2183,7 +2240,7 @@ export function createAgentGraph(options = {}) {
|
|
|
2183
2240
|
// language like the `/turn` skill path. No further model turn: the skill
|
|
2184
2241
|
// is launched and owns execution, so nothing can contradict it.
|
|
2185
2242
|
const response = await generateSkillAcknowledgment(state.session, skillLaunch).catch(
|
|
2186
|
-
() => `Started ${skillLaunch.publicInput} — ${skillLaunch.objectives} step(s)
|
|
2243
|
+
() => `Started ${skillLaunch.publicInput} — ${skillLaunch.objectives} step(s) queued.`,
|
|
2187
2244
|
);
|
|
2188
2245
|
return {
|
|
2189
2246
|
messages: toolResultMessages,
|
package/src/agent/graph.test.js
CHANGED
|
@@ -626,7 +626,7 @@ test('an explicitly selected skill runs through the intra-runtime path with name
|
|
|
626
626
|
const result = await createAgentGraph().invoke({ input: 'lance le skill deliver avec le template Quarterly report', session });
|
|
627
627
|
// Launching a skill ends the turn: the acknowledgement is generated once and
|
|
628
628
|
// the model is not given a second chance to re-delegate or contradict it.
|
|
629
|
-
assert.equal(result.response, 'Started /deliver deliverable="Quarterly report" — 1 step(s)
|
|
629
|
+
assert.equal(result.response, 'Started /deliver deliverable="Quarterly report" — 1 step(s) queued.');
|
|
630
630
|
assert.equal(mainCalls, 1);
|
|
631
631
|
// `skillStack` accompagne désormais la demande : le run imbriqué démarre après
|
|
632
632
|
// le nettoyage de celui-ci, et c'est le seul canal par lequel il peut savoir
|
|
@@ -678,6 +678,14 @@ test('a natural-language skill match cannot drop declared scope and fall back to
|
|
|
678
678
|
const toolResult = session.agentEvents.find((event) => event.type === 'tool_call_result');
|
|
679
679
|
assert.match(toolResult?.payload?.result ?? '', /missingParameters/);
|
|
680
680
|
assert.match(toolResult?.payload?.result ?? '', /never replace a missing parameter with an unscoped/);
|
|
681
|
+
// "Ask the user for the missing scope" is a conversational blocker, not an
|
|
682
|
+
// execution failure — the turn is working exactly as intended. Marking it
|
|
683
|
+
// failed published `runtime__run_skill failed: {…}` into the progress and
|
|
684
|
+
// activity surfaces; the `delegate` branch handles the identical case as a
|
|
685
|
+
// non-failure, and the two must not disagree.
|
|
686
|
+
assert.equal(toolResult?.payload?.ok, true);
|
|
687
|
+
const progress = session.agentEvents.find((event) => event.type === 'assistant_progress');
|
|
688
|
+
assert.doesNotMatch(progress?.payload?.message ?? '', /failed/i);
|
|
681
689
|
} finally {
|
|
682
690
|
rmSync(root, { recursive: true, force: true });
|
|
683
691
|
}
|
|
@@ -709,6 +717,110 @@ test('a terminal skill refusal stops the whole turn before a delegate fallback',
|
|
|
709
717
|
assert.equal(delegated, false);
|
|
710
718
|
});
|
|
711
719
|
|
|
720
|
+
test('a narrated tool call on the tool-less synthesis turn is never shown, and nothing runs', async () => {
|
|
721
|
+
// After a terminal failure the synthesis turn offers no tools; a model that
|
|
722
|
+
// still wants to act writes the call as text (`runtime__delegate{"…"}`) and
|
|
723
|
+
// the turn did nothing. That raw call must never reach the user.
|
|
724
|
+
let delegated = false;
|
|
725
|
+
const narrated = 'runtime__delegate{"objective":"nettoyer le wiki"}';
|
|
726
|
+
const session = sessionBase({
|
|
727
|
+
runtime: { url: 'http://runtime.test' },
|
|
728
|
+
_runSkillWithinRun: async () => ({ ok: false, terminal: true, code: 'skill_not_found', availableSkills: [] }),
|
|
729
|
+
_delegateWithinRun: async () => { delegated = true; return { runId: 'bad' }; },
|
|
730
|
+
llm: {
|
|
731
|
+
async completeWithTools({ tools }) {
|
|
732
|
+
if (tools.some((tool) => tool.function?.name === 'classify_action_request')) {
|
|
733
|
+
return { content: null, message: { role: 'assistant', content: null }, tool_calls: [{ id: 'classify', type: 'function', function: { name: 'classify_action_request', arguments: '{"action":true}' } }] };
|
|
734
|
+
}
|
|
735
|
+
if (tools.length > 0) {
|
|
736
|
+
return { content: null, message: { role: 'assistant', content: null }, tool_calls: [
|
|
737
|
+
{ id: 'missing', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"missing","selectionKind":"explicit_name"}' } },
|
|
738
|
+
] };
|
|
739
|
+
}
|
|
740
|
+
return { content: narrated, message: { role: 'assistant', content: narrated }, tool_calls: null };
|
|
741
|
+
},
|
|
742
|
+
},
|
|
743
|
+
});
|
|
744
|
+
const result = await createAgentGraph().invoke({ input: 'nettoie le wiki', session });
|
|
745
|
+
assert.equal(delegated, false);
|
|
746
|
+
assert.match(result.response, /printed an internal tool request/);
|
|
747
|
+
assert.doesNotMatch(result.response, /runtime__delegate/);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
test('a recoverable skill refusal lets the delegate fallback run in the same turn', async () => {
|
|
751
|
+
// The observed defect: the model guessed `/diagnose`, the skill runner
|
|
752
|
+
// answered skill_not_found, and because that was terminal the tools were
|
|
753
|
+
// stripped from the next turn — the model then wrote
|
|
754
|
+
// `runtime__delegate{...}` as plain text and the turn did nothing. A guessed
|
|
755
|
+
// skill must be recoverable so the fallback can actually run.
|
|
756
|
+
let delegated = false;
|
|
757
|
+
const session = sessionBase({
|
|
758
|
+
runtime: { url: 'http://runtime.test' },
|
|
759
|
+
_runSkillWithinRun: async () => ({
|
|
760
|
+
ok: false,
|
|
761
|
+
terminal: false,
|
|
762
|
+
code: 'skill_not_found',
|
|
763
|
+
message: 'No skill named "/diagnose". Pass the exact name without a leading slash, or delegate the objective with runtime__delegate.',
|
|
764
|
+
availableSkills: ['diagnose'],
|
|
765
|
+
}),
|
|
766
|
+
_delegateWithinRun: async () => { delegated = true; return { runId: 'run-1', summary: { tasks: 1, agent: 'gateway' } }; },
|
|
767
|
+
llm: {
|
|
768
|
+
async completeWithTools({ tools }) {
|
|
769
|
+
if (tools.some((tool) => tool.function?.name === 'classify_action_request')) {
|
|
770
|
+
return { content: null, message: { role: 'assistant', content: null }, tool_calls: [{ id: 'classify', type: 'function', function: { name: 'classify_action_request', arguments: '{"action":true}' } }] };
|
|
771
|
+
}
|
|
772
|
+
return {
|
|
773
|
+
content: null, message: { role: 'assistant', content: null },
|
|
774
|
+
tool_calls: [
|
|
775
|
+
{ id: 'guess', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"/diagnose","arguments":{}}' } },
|
|
776
|
+
{ id: 'fallback', type: 'function', function: { name: 'runtime__delegate', arguments: '{"objective":"nettoyer le wiki, corriger les doublons et les affirmations non sourcées"}' } },
|
|
777
|
+
],
|
|
778
|
+
};
|
|
779
|
+
},
|
|
780
|
+
},
|
|
781
|
+
});
|
|
782
|
+
const result = await createAgentGraph().invoke({ input: 'nettoie le wiki, corrige les doublons et les affirmations non sourcées', session });
|
|
783
|
+
assert.equal(delegated, true);
|
|
784
|
+
assert.notEqual(result.terminalToolFailure, true);
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
test('an ACCEPTED skill launch stops the rest of the batch, and says what it dropped', async () => {
|
|
788
|
+
// The mirror of the test above: a refusal must let the fallback run, an
|
|
789
|
+
// acceptance must not. The skill owns execution from there, but the loop
|
|
790
|
+
// kept going — a companion runtime__delegate in the same batch started a
|
|
791
|
+
// second, independent run while only the skill was acknowledged, with
|
|
792
|
+
// nothing in the thread naming the delegated one.
|
|
793
|
+
let delegated = false;
|
|
794
|
+
const session = sessionBase({
|
|
795
|
+
runtime: { url: 'http://runtime.test' },
|
|
796
|
+
_runSkillWithinRun: async () => ({ accepted: true, skill: 'pipeline', objectiveCount: 1 }),
|
|
797
|
+
_delegateWithinRun: async () => { delegated = true; return { runId: 'run-2', summary: { tasks: 1 } }; },
|
|
798
|
+
llm: {
|
|
799
|
+
async completeWithTools({ tools }) {
|
|
800
|
+
if (tools.some((tool) => tool.function?.name === 'classify_action_request')) {
|
|
801
|
+
return { content: null, message: { role: 'assistant', content: null }, tool_calls: [{ id: 'classify', type: 'function', function: { name: 'classify_action_request', arguments: '{"action":true}' } }] };
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
content: null, message: { role: 'assistant', content: null },
|
|
805
|
+
tool_calls: [
|
|
806
|
+
{ id: 'skill', type: 'function', function: { name: 'runtime__run_skill', arguments: '{"skillName":"pipeline","selectionKind":"explicit_name"}' } },
|
|
807
|
+
{ id: 'also', type: 'function', function: { name: 'runtime__delegate', arguments: '{"objective":"construire le livrable"}' } },
|
|
808
|
+
],
|
|
809
|
+
};
|
|
810
|
+
},
|
|
811
|
+
},
|
|
812
|
+
});
|
|
813
|
+
await createAgentGraph().invoke({ input: '/pipeline', session });
|
|
814
|
+
assert.equal(delegated, false, 'the delegate must not start a second run behind the skill');
|
|
815
|
+
// …and the skipped call is announced rather than silently dropped, with a
|
|
816
|
+
// tool result so a replayed history is not missing one.
|
|
817
|
+
const skipped = session.agentEvents.find(
|
|
818
|
+
(event) => event.type === 'tool_call_result' && event.payload?.callId === 'also',
|
|
819
|
+
);
|
|
820
|
+
assert.equal(skipped?.payload?.summary, 'skipped');
|
|
821
|
+
assert.match(skipped?.payload?.result ?? '', /owns execution/);
|
|
822
|
+
});
|
|
823
|
+
|
|
712
824
|
test('tool argument normalization repairs only an unambiguous schema-compatible field name', () => {
|
|
713
825
|
const schema = {
|
|
714
826
|
type: 'object',
|
package/src/cli/wiki-manager.js
CHANGED
|
@@ -1725,6 +1725,13 @@ async function runRuntime(argv, agent) {
|
|
|
1725
1725
|
async function executeInteractiveTurn(context, body, { signal, turnId } = {}) {
|
|
1726
1726
|
const input = String(body.input ?? body.prompt ?? '').trim();
|
|
1727
1727
|
if (!input) throw new Error('Missing input.');
|
|
1728
|
+
// The reader's own words, when the caller augmented `input` with system
|
|
1729
|
+
// facts (a status question gets the runtime's fact block appended for the
|
|
1730
|
+
// model). The thread, the SSE stream and the replayed history must all
|
|
1731
|
+
// show what was typed — publishing the fact block as the user's message
|
|
1732
|
+
// put a raw English dump in their bubble and seeded every later turn with
|
|
1733
|
+
// it.
|
|
1734
|
+
const displayInput = String(body.displayInput ?? '').trim() || input;
|
|
1728
1735
|
// The runtime may start while optional agents are still stopped. `/start
|
|
1729
1736
|
// agents` happens in the shell process, so its refreshed MCP snapshot does
|
|
1730
1737
|
// not mutate this long-lived runtime context. Re-probe only while at least
|
|
@@ -1776,7 +1783,7 @@ async function runRuntime(argv, agent) {
|
|
|
1776
1783
|
origin: 'runtime_turn',
|
|
1777
1784
|
turnId,
|
|
1778
1785
|
workspace: context.workspace ?? null,
|
|
1779
|
-
payload: { content:
|
|
1786
|
+
payload: { content: displayInput },
|
|
1780
1787
|
}));
|
|
1781
1788
|
// Read-only chat turn: same chatAccess policy as the Shell UI's /chat, now
|
|
1782
1789
|
// reachable over HTTP so `wiki serve` chat mode gets read tools without
|
|
@@ -1792,39 +1799,46 @@ async function runRuntime(argv, agent) {
|
|
|
1792
1799
|
body.context?.openWikiPages ?? body.context?.openWikiPage,
|
|
1793
1800
|
);
|
|
1794
1801
|
let response;
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1802
|
+
try {
|
|
1803
|
+
if (chatMode) {
|
|
1804
|
+
ephemeral.chatMode = true;
|
|
1805
|
+
ephemeral.chatAccess = readChatAccessConfig();
|
|
1806
|
+
const history = messages.length && messages[messages.length - 1]?.role === 'user'
|
|
1807
|
+
? messages.slice(0, -1)
|
|
1808
|
+
: messages;
|
|
1809
|
+
response = await runHeadlessChatTurn(ephemeral, input, {
|
|
1810
|
+
history,
|
|
1811
|
+
onStep: ephemeral._onStep,
|
|
1812
|
+
// Fragments de réponse publiés au fil de l'eau, coalescés (voir
|
|
1813
|
+
// deltaCoalescer ci-dessus). Le réducteur les agrège dans la dernière
|
|
1814
|
+
// entrée de conversation (`assistant_delta`), que `assistant_message`
|
|
1815
|
+
// vient ensuite figer : les deux interfaces voient la réponse s'écrire
|
|
1816
|
+
// sans qu'un insert SQLite par token ne bloque le flux.
|
|
1817
|
+
onTextDelta: (delta) => deltaCoalescer.push(delta),
|
|
1818
|
+
onTextReset: () => {
|
|
1819
|
+
deltaCoalescer.reset();
|
|
1820
|
+
dispatchAgentEvent(ephemeral, createAgentEvent('assistant_delta_reset', {
|
|
1821
|
+
origin: 'runtime_turn',
|
|
1822
|
+
turnId,
|
|
1823
|
+
workspace: context.workspace ?? null,
|
|
1824
|
+
payload: {},
|
|
1825
|
+
}));
|
|
1826
|
+
},
|
|
1827
|
+
openWikiPages,
|
|
1828
|
+
});
|
|
1829
|
+
} else {
|
|
1830
|
+
ephemeral.openWikiPages = openWikiPages;
|
|
1831
|
+
response = await runAgentTurn(agent, ephemeral, input, { messages, signal });
|
|
1832
|
+
}
|
|
1833
|
+
} finally {
|
|
1834
|
+
// In a `finally`, not after the await: a throwing or aborted turn left
|
|
1835
|
+
// the 80 ms timer armed, so a stray assistant_delta fired AFTER the
|
|
1836
|
+
// "Runtime turn failed" message and appended orphan fragments to the
|
|
1837
|
+
// wrong conversation entry — and the handle kept the session closure
|
|
1838
|
+
// alive. Flush the tail first so ordering survives either way.
|
|
1839
|
+
deltaCoalescer.flush();
|
|
1840
|
+
deltaCoalescer.dispose();
|
|
1824
1841
|
}
|
|
1825
|
-
// Flush the tail before the turn is finalized, then stop the timer.
|
|
1826
|
-
deltaCoalescer.flush();
|
|
1827
|
-
deltaCoalescer.dispose();
|
|
1828
1842
|
// Persist the artifact the turn may have opened/edited (template_write,
|
|
1829
1843
|
// template_read, …) back onto the long-lived session, so the next /turn —
|
|
1830
1844
|
// chat or agent — sees it. The ephemeral session is otherwise discarded.
|
package/src/commands/slash.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { isTerminal } from '../orchestrator/taskStatuses.js';
|
|
11
11
|
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
12
|
-
import { openExternalUrl } from '../shell/openExternal.js';
|
|
12
|
+
import { openAppWindowUrl, openExternalUrl } from '../shell/openExternal.js';
|
|
13
13
|
import { classifyCommandFailure, failureHint, rawFailureText } from '../core/commandFailure.js';
|
|
14
14
|
import { basename, join, relative } from 'node:path';
|
|
15
15
|
import { composeServices, listServices, otherWorkspacesRunning, runWikiCli, serviceLogs, serviceNames, serviceStates, startService, stopService } from '../core/compose.js';
|
|
@@ -845,7 +845,7 @@ ${helpPair('/upload <path>', 'Upload document', '/uploads', 'Uploaded docs')}
|
|
|
845
845
|
${helpPair('/upload convert pending', 'Convert pending', '/uploads clean', 'Clean uploads')}
|
|
846
846
|
${helpPair('/wiki', 'Run wiki index', '/wiki run <args>', 'Raw wiki CLI')}
|
|
847
847
|
${helpPair('/chat', 'Chat mode', '/agent [question]', 'Agent mode / one-shot')}
|
|
848
|
-
${helpPair('/openui', 'Open web UI
|
|
848
|
+
${helpPair('/openui', 'Open web UI as a desktop window', '', '')}
|
|
849
849
|
${helpPair('/run status', 'Runtime status', '/run kill', 'Kill runtime run(s)')}
|
|
850
850
|
${helpPair('/run capability <id>', 'Deterministic capability run', '/approve', 'Grant pending approval')}
|
|
851
851
|
${helpPair('/cancel', 'Cancel active run', '', '')}
|
|
@@ -1770,6 +1770,11 @@ export async function handleSlashCommand(line, context) {
|
|
|
1770
1770
|
// No readable session (gate off, or a custom state dir): plain URL.
|
|
1771
1771
|
}
|
|
1772
1772
|
const note = context.session.workspaceEnv ? '' : ' (no workspace loaded — using default port)';
|
|
1773
|
+
// App-mode first: a chromeless Chrome/Edge window picks up serve's own
|
|
1774
|
+
// manifest (name, icon, window-controls-overlay) with no prior "Install"
|
|
1775
|
+
// step. Falls back to the plain default-browser tab when neither is
|
|
1776
|
+
// found (Safari/Firefox-only machines).
|
|
1777
|
+
if (openAppWindowUrl(openUrl)) return { output: `Opening web UI: ${url}${note}` };
|
|
1773
1778
|
if (openExternalUrl(openUrl)) return { output: `Opening web UI: ${url}${note}` };
|
|
1774
1779
|
return { output: `Web UI: ${url}${note}` };
|
|
1775
1780
|
}
|
package/src/core/agentEvents.js
CHANGED
|
@@ -6,7 +6,7 @@ import { formatRuntimeLogPayload, isDispatchPlumbingLine, normalizeRuntimeLog, s
|
|
|
6
6
|
import { projectSkillChains, TERMINAL as CONTROL_TERMINAL_STATUSES } from './skillChainView.js';
|
|
7
7
|
import { projectWorkflow } from './workflow.js';
|
|
8
8
|
import { validateContractInDev } from '../contracts/schemas.js';
|
|
9
|
-
import { isTerminal, isSuccessful, isUnknownStatus, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
|
|
9
|
+
import { isActive, isTerminal, isSuccessful, isUnknownStatus, normalizeTaskStatus } from '../orchestrator/taskStatuses.js';
|
|
10
10
|
|
|
11
11
|
const SESSION_PROJECTION_EVENTS = new Set([
|
|
12
12
|
'run_started',
|
|
@@ -292,6 +292,11 @@ export function applyAgentProjectionToSession(session, projection) {
|
|
|
292
292
|
} : session.productionActivity ?? null;
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
+
function hasRunningPlanStep(state) {
|
|
296
|
+
return (Array.isArray(state.plan) ? state.plan : [])
|
|
297
|
+
.some((step) => isActive(step?.status));
|
|
298
|
+
}
|
|
299
|
+
|
|
295
300
|
function applyEvent(state, event) {
|
|
296
301
|
switch (event.type) {
|
|
297
302
|
case 'run_started':
|
|
@@ -598,6 +603,13 @@ function applyEvent(state, event) {
|
|
|
598
603
|
reason: event.payload?.reason ?? null,
|
|
599
604
|
createdAt: event.ts,
|
|
600
605
|
});
|
|
606
|
+
// A run waiting for a human is not "running": showing it as running is
|
|
607
|
+
// how the chat could claim a rebuild was executing before anyone
|
|
608
|
+
// approved it. Mirror `run_pending_approval` (AGENTS.md: the run status
|
|
609
|
+
// is pending_approval while the decision is outstanding), but only when
|
|
610
|
+
// no task is actually executing — a parallel run may have work in flight
|
|
611
|
+
// while one branch waits.
|
|
612
|
+
if (!hasRunningPlanStep(state)) state.status = 'pending_approval';
|
|
601
613
|
return;
|
|
602
614
|
case 'approval.granted': {
|
|
603
615
|
const grant = {
|
|
@@ -617,6 +629,13 @@ function applyEvent(state, event) {
|
|
|
617
629
|
};
|
|
618
630
|
upsertApproval(state, grant);
|
|
619
631
|
markCoveredApprovalsApproved(state.approvals, grant, event.ts);
|
|
632
|
+
// The decision is in: the run goes back to running unless another
|
|
633
|
+
// approval is still outstanding (a run-scoped grant clears its covered
|
|
634
|
+
// ones, markCoveredApprovalsApproved above).
|
|
635
|
+
if (state.status === 'pending_approval'
|
|
636
|
+
&& !(state.approvals ?? []).some((approval) => approval.status === 'pending_approval')) {
|
|
637
|
+
state.status = 'running';
|
|
638
|
+
}
|
|
620
639
|
return;
|
|
621
640
|
}
|
|
622
641
|
case 'approval.rejected':
|
|
@@ -635,6 +654,17 @@ function applyEvent(state, event) {
|
|
|
635
654
|
reason: event.payload?.reason ?? null,
|
|
636
655
|
rejectedAt: event.ts,
|
|
637
656
|
});
|
|
657
|
+
// Same latch release as `approval.granted`: a refusal IS a decision. Only
|
|
658
|
+
// `granted` cleared the status, so a rejected approval left the
|
|
659
|
+
// projection reporting `pending_approval` for the rest of the run —
|
|
660
|
+
// both UIs kept asking for a decision the user had already made, while
|
|
661
|
+
// `explainControlState` found no pending approval and answered "run is
|
|
662
|
+
// active". The two surfaces disagreed until some later run_done
|
|
663
|
+
// overwrote it.
|
|
664
|
+
if (state.status === 'pending_approval'
|
|
665
|
+
&& !(state.approvals ?? []).some((approval) => approval.status === 'pending_approval')) {
|
|
666
|
+
state.status = 'running';
|
|
667
|
+
}
|
|
638
668
|
return;
|
|
639
669
|
case 'run_done':
|
|
640
670
|
state.status = 'done';
|
|
@@ -127,6 +127,65 @@ test('reduceAgentEvents: interactive (user) run_started clears state but is not
|
|
|
127
127
|
assert.notEqual(projection.status, 'running');
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
+
test('reduceAgentEvents: a run blocked on approval is not shown as running', () => {
|
|
131
|
+
// The chat claimed a rebuild was executing while its only task was still
|
|
132
|
+
// waiting for a human: the run status stayed 'running' through the per-task
|
|
133
|
+
// approval request. It must mirror run_pending_approval.
|
|
134
|
+
const projection = reduceAgentEvents([
|
|
135
|
+
createAgentEvent('run_started', { origin: 'runtime' }),
|
|
136
|
+
createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Rebuild the concepts'] } }),
|
|
137
|
+
createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'waiting_approval' } }),
|
|
138
|
+
createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
|
|
139
|
+
]);
|
|
140
|
+
assert.equal(projection.status, 'pending_approval');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('reduceAgentEvents: granting the approval puts the run back to running', () => {
|
|
144
|
+
const projection = reduceAgentEvents([
|
|
145
|
+
createAgentEvent('run_started', { origin: 'runtime' }),
|
|
146
|
+
createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Rebuild the concepts'] } }),
|
|
147
|
+
createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'waiting_approval' } }),
|
|
148
|
+
createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
|
|
149
|
+
createAgentEvent('approval.granted', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
|
|
150
|
+
]);
|
|
151
|
+
assert.equal(projection.status, 'running');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('reduceAgentEvents: REJECTING the approval also puts the run back to running', () => {
|
|
155
|
+
// A refusal is a decision. Only `granted` cleared the latch, so a rejected
|
|
156
|
+
// approval left both UIs asking for a decision already made, for the rest of
|
|
157
|
+
// the run.
|
|
158
|
+
const projection = reduceAgentEvents([
|
|
159
|
+
createAgentEvent('run_started', { origin: 'runtime' }),
|
|
160
|
+
createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Rebuild the concepts'] } }),
|
|
161
|
+
createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'waiting_approval' } }),
|
|
162
|
+
createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
|
|
163
|
+
createAgentEvent('approval.rejected', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
|
|
164
|
+
]);
|
|
165
|
+
assert.equal(projection.status, 'running');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('reduceAgentEvents: rejecting one of two approvals keeps the run blocked', () => {
|
|
169
|
+
const projection = reduceAgentEvents([
|
|
170
|
+
createAgentEvent('run_started', { origin: 'runtime' }),
|
|
171
|
+
createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Export', 'Build'] } }),
|
|
172
|
+
createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
|
|
173
|
+
createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a2', scope: 'task', taskId: 't2' } }),
|
|
174
|
+
createAgentEvent('approval.rejected', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't1' } }),
|
|
175
|
+
]);
|
|
176
|
+
assert.equal(projection.status, 'pending_approval');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('reduceAgentEvents: an approval request does not hide a genuinely running task', () => {
|
|
180
|
+
const projection = reduceAgentEvents([
|
|
181
|
+
createAgentEvent('run_started', { origin: 'runtime' }),
|
|
182
|
+
createAgentEvent('plan_set', { origin: 'tool', payload: { steps: ['Export', 'Build'] } }),
|
|
183
|
+
createAgentEvent('plan_step_updated', { origin: 'runtime', payload: { step: 1, status: 'running' } }),
|
|
184
|
+
createAgentEvent('approval.requested', { origin: 'runtime', payload: { id: 'a1', scope: 'task', taskId: 't2' } }),
|
|
185
|
+
]);
|
|
186
|
+
assert.equal(projection.status, 'running');
|
|
187
|
+
});
|
|
188
|
+
|
|
130
189
|
test('reduceAgentEvents: tracks manual plan and step updates', () => {
|
|
131
190
|
const projection = reduceAgentEvents([
|
|
132
191
|
createAgentEvent('plan_set', {
|
package/src/core/buildInfo.json
CHANGED
package/src/core/mcp.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { managerEnvFile, managerMcpEndpointsFile, readEnvFile } from './env.js';
|
|
3
3
|
|
|
4
|
-
const WIKI_MANAGER_VERSION = '0.15.
|
|
4
|
+
const WIKI_MANAGER_VERSION = '0.15.98';
|
|
5
5
|
|
|
6
6
|
function envValue(key) {
|
|
7
7
|
const filePath = managerEnvFile();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
|
9
|
+
|
|
10
|
+
// The `npm test` argument list is hand-maintained, and it has drifted twice:
|
|
11
|
+
// four files were once found outside the gate, and mcpEndpoints.test.js was
|
|
12
|
+
// green on disk while nothing ran it. A test nobody runs is worse than no
|
|
13
|
+
// test — it reads as coverage. This is the check that says so.
|
|
14
|
+
test('every test file on disk is in the npm test gate', () => {
|
|
15
|
+
const pkg = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8'));
|
|
16
|
+
const listed = new Set(
|
|
17
|
+
pkg.scripts.test.split(/\s+/).filter((argument) => argument.endsWith('.test.js')),
|
|
18
|
+
);
|
|
19
|
+
const onDisk = execFileSync('find', ['src', '-name', '*.test.js'], {
|
|
20
|
+
cwd: repoRoot,
|
|
21
|
+
encoding: 'utf8',
|
|
22
|
+
})
|
|
23
|
+
.trim()
|
|
24
|
+
.split('\n')
|
|
25
|
+
.filter(Boolean)
|
|
26
|
+
.sort();
|
|
27
|
+
|
|
28
|
+
const missing = onDisk.filter((file) => !listed.has(file));
|
|
29
|
+
assert.deepEqual(missing, [], `test files not run by \`npm test\`:\n${missing.join('\n')}`);
|
|
30
|
+
|
|
31
|
+
const stale = [...listed].filter((file) => !onDisk.includes(file));
|
|
32
|
+
assert.deepEqual(stale, [], `\`npm test\` names files that no longer exist:\n${stale.join('\n')}`);
|
|
33
|
+
});
|
package/src/core/toolLoop.js
CHANGED
|
@@ -129,7 +129,13 @@ async function finalAnswerWithoutTools({
|
|
|
129
129
|
});
|
|
130
130
|
if (result?.tool_calls?.length) return '';
|
|
131
131
|
return String(result?.content ?? result?.message?.content ?? '').trim();
|
|
132
|
-
} catch {
|
|
132
|
+
} catch (err) {
|
|
133
|
+
// An abort is the user cancelling, not an empty answer. Swallowing it here
|
|
134
|
+
// made `runBoundedToolLoop` return `{ content: '', capped: true }`, and the
|
|
135
|
+
// caller printed the iteration-limit notice for a turn that was cancelled
|
|
136
|
+
// — the loop's contract is that an abort escapes, and this was the one
|
|
137
|
+
// call that broke it.
|
|
138
|
+
if (err?.name === 'AbortError' || signal?.aborted) throw err;
|
|
133
139
|
return '';
|
|
134
140
|
}
|
|
135
141
|
}
|