lynkr 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CITATIONS.bib +6 -0
  2. package/DEPLOYMENT.md +1001 -0
  3. package/README.md +215 -71
  4. package/docs/index.md +55 -2
  5. package/monitor-agents.sh +31 -0
  6. package/package.json +7 -3
  7. package/src/agents/context-manager.js +220 -0
  8. package/src/agents/definitions/loader.js +563 -0
  9. package/src/agents/executor.js +412 -0
  10. package/src/agents/index.js +157 -0
  11. package/src/agents/parallel-coordinator.js +68 -0
  12. package/src/agents/reflector.js +321 -0
  13. package/src/agents/skillbook.js +331 -0
  14. package/src/agents/store.js +244 -0
  15. package/src/api/router.js +55 -0
  16. package/src/clients/databricks.js +214 -17
  17. package/src/clients/routing.js +15 -7
  18. package/src/clients/standard-tools.js +341 -0
  19. package/src/config/index.js +41 -5
  20. package/src/orchestrator/index.js +254 -37
  21. package/src/server.js +2 -0
  22. package/src/tools/agent-task.js +96 -0
  23. package/test/azure-openai-config.test.js +203 -0
  24. package/test/azure-openai-error-resilience.test.js +238 -0
  25. package/test/azure-openai-format-conversion.test.js +354 -0
  26. package/test/azure-openai-integration.test.js +281 -0
  27. package/test/azure-openai-routing.test.js +148 -0
  28. package/test/azure-openai-streaming.test.js +171 -0
  29. package/test/format-conversion.test.js +578 -0
  30. package/test/hybrid-routing-integration.test.js +18 -11
  31. package/test/openrouter-error-resilience.test.js +418 -0
  32. package/test/passthrough-mode.test.js +385 -0
  33. package/test/routing.test.js +9 -3
  34. package/test/web-tools.test.js +3 -0
  35. package/test-agents-simple.js +43 -0
  36. package/test-cli-connection.sh +33 -0
  37. package/test-learning-unit.js +126 -0
  38. package/test-learning.js +112 -0
  39. package/test-parallel-agents.sh +124 -0
  40. package/test-parallel-direct.js +155 -0
  41. package/test-subagents.sh +117 -0
@@ -699,10 +699,10 @@ function toAnthropicResponse(openai, requestedModel, wantsThinking) {
699
699
  choice?.finish_reason === "stop"
700
700
  ? "end_turn"
701
701
  : choice?.finish_reason === "length"
702
- ? "max_tokens"
703
- : choice?.finish_reason === "tool_calls"
704
- ? "tool_use"
705
- : choice?.finish_reason ?? "end_turn",
702
+ ? "max_tokens"
703
+ : choice?.finish_reason === "tool_calls"
704
+ ? "tool_use"
705
+ : choice?.finish_reason ?? "end_turn",
706
706
  stop_sequence: null,
707
707
  usage: {
708
708
  input_tokens: usage.prompt_tokens ?? 0,
@@ -874,9 +874,9 @@ function sanitizePayload(payload) {
874
874
  tools && tools.length > 0
875
875
  ? tools
876
876
  : DEFAULT_AZURE_TOOLS.map((tool) => ({
877
- name: tool.name,
878
- input_schema: JSON.parse(JSON.stringify(tool.input_schema)),
879
- }));
877
+ name: tool.name,
878
+ input_schema: JSON.parse(JSON.stringify(tool.input_schema)),
879
+ }));
880
880
  delete clean.tool_choice;
881
881
  } else if (providerType === "ollama") {
882
882
  // Check if model supports tools
@@ -1122,7 +1122,7 @@ async function runAgentLoop({
1122
1122
  "Azure Anthropic request payload structure",
1123
1123
  );
1124
1124
  }
1125
-
1125
+
1126
1126
  const databricksResponse = await invokeModel(cleanPayload);
1127
1127
 
1128
1128
  // Handle streaming responses (pass through without buffering)
@@ -1195,7 +1195,7 @@ async function runAgentLoop({
1195
1195
  // Extract message and tool calls based on provider response format
1196
1196
  let message = {};
1197
1197
  let toolCalls = [];
1198
-
1198
+
1199
1199
  if (providerType === "azure-anthropic") {
1200
1200
  // Anthropic format: { content: [{ type: "tool_use", ... }], stop_reason: "tool_use" }
1201
1201
  message = {
@@ -1203,8 +1203,8 @@ async function runAgentLoop({
1203
1203
  stop_reason: databricksResponse.json?.stop_reason,
1204
1204
  };
1205
1205
  // Extract tool_use blocks from content array
1206
- const contentArray = Array.isArray(databricksResponse.json?.content)
1207
- ? databricksResponse.json.content
1206
+ const contentArray = Array.isArray(databricksResponse.json?.content)
1207
+ ? databricksResponse.json.content
1208
1208
  : [];
1209
1209
  toolCalls = contentArray
1210
1210
  .filter(block => block?.type === "tool_use")
@@ -1217,7 +1217,7 @@ async function runAgentLoop({
1217
1217
  // Keep original block for reference
1218
1218
  _anthropic_block: block,
1219
1219
  }));
1220
-
1220
+
1221
1221
  logger.debug(
1222
1222
  {
1223
1223
  sessionId: session?.id ?? null,
@@ -1331,23 +1331,51 @@ async function runAgentLoop({
1331
1331
 
1332
1332
  // Check if tool execution should happen on client side
1333
1333
  const executionMode = config.toolExecutionMode || "server";
1334
- if (executionMode === "passthrough" || executionMode === "client") {
1334
+
1335
+ // IMPORTANT: Task tools (subagents) and Web Search tools ALWAYS execute server-side, regardless of execution mode to ensure reliability
1336
+ // Separate Server-side tools from Client-side tools
1337
+ const serverSideToolCalls = [];
1338
+ const clientSideToolCalls = [];
1339
+
1340
+ const SERVER_SIDE_TOOLS = new Set(["task", "web_search", "web_fetch", "websearch", "webfetch"]);
1341
+
1342
+ for (const call of toolCalls) {
1343
+ const toolName = (call.function?.name ?? call.name ?? "").toLowerCase();
1344
+ if (SERVER_SIDE_TOOLS.has(toolName)) {
1345
+ serverSideToolCalls.push(call);
1346
+ } else {
1347
+ clientSideToolCalls.push(call);
1348
+ }
1349
+ }
1350
+
1351
+ // If in passthrough/client mode and there are client-side tools, return them to client
1352
+ // Server-side tools (Task, Web) will be executed below
1353
+ if ((executionMode === "passthrough" || executionMode === "client") && clientSideToolCalls.length > 0) {
1335
1354
  logger.info(
1336
1355
  {
1337
1356
  sessionId: session?.id ?? null,
1338
- toolCount: toolCalls.length,
1357
+ totalToolCount: toolCalls.length,
1358
+ serverToolCount: serverSideToolCalls.length,
1359
+ clientToolCount: clientSideToolCalls.length,
1339
1360
  executionMode,
1340
- toolNames: toolCalls.map((c) => c.function?.name ?? c.name),
1361
+ clientTools: clientSideToolCalls.map((c) => c.function?.name ?? c.name),
1341
1362
  },
1342
- "Passthrough mode: returning tool calls to client for execution"
1363
+ "Hybrid mode: returning non-Task tools to client, executing Task tools on server"
1343
1364
  );
1344
1365
 
1366
+ // Filter sessionContent to only include client-side tool_use blocks
1367
+ const clientContent = sessionContent.filter(block => {
1368
+ if (block.type !== "tool_use") return true; // Keep text blocks
1369
+ const toolName = (block.name ?? "").toLowerCase();
1370
+ return !SERVER_SIDE_TOOLS.has(toolName); // Keep client-side tool_use blocks
1371
+ });
1372
+
1345
1373
  // Convert OpenRouter response to Anthropic format for CLI
1346
1374
  const anthropicResponse = {
1347
1375
  id: databricksResponse.json?.id || `msg_${Date.now()}`,
1348
1376
  type: "message",
1349
1377
  role: "assistant",
1350
- content: sessionContent, // Already in Anthropic format with tool_use blocks
1378
+ content: clientContent,
1351
1379
  model: databricksResponse.json?.model || clean.model,
1352
1380
  stop_reason: "tool_use",
1353
1381
  usage: databricksResponse.json?.usage || {
@@ -1356,32 +1384,51 @@ async function runAgentLoop({
1356
1384
  },
1357
1385
  };
1358
1386
 
1359
- // Debug: Log the actual content being returned
1360
1387
  logger.debug(
1361
1388
  {
1362
1389
  sessionId: session?.id ?? null,
1363
- contentLength: Array.isArray(sessionContent) ? sessionContent.length : 0,
1364
- contentTypes: Array.isArray(sessionContent) ? sessionContent.map(b => b.type) : [],
1365
- firstBlock: Array.isArray(sessionContent) && sessionContent.length > 0 ? sessionContent[0] : null,
1366
- responseId: anthropicResponse.id,
1367
- stopReason: anthropicResponse.stop_reason,
1390
+ clientContentLength: clientContent.length,
1391
+ clientContentTypes: clientContent.map(b => b.type),
1368
1392
  },
1369
- "Passthrough: returning Anthropic-formatted response with content blocks"
1393
+ "Passthrough: returning client-side tools to client"
1370
1394
  );
1371
1395
 
1372
- // Return Anthropic-formatted response to CLI
1373
- // The CLI will execute the tools and send another request with tool_result blocks
1374
- // IMPORTANT: Must match agent loop return format (response wrapper)
1375
- return {
1376
- response: {
1377
- status: 200,
1378
- body: anthropicResponse,
1396
+ // If there are server-side tools, we need to execute them server-side first
1397
+ // then continue the conversation loop. For now, let's fall through to execute server-side tools.
1398
+ if (serverSideToolCalls.length === 0) {
1399
+ // No server-side tools - pure passthrough
1400
+ return {
1401
+ response: {
1402
+ status: 200,
1403
+ body: anthropicResponse,
1404
+ terminationReason: "tool_use",
1405
+ },
1406
+ steps,
1407
+ durationMs: Date.now() - start,
1379
1408
  terminationReason: "tool_use",
1409
+ };
1410
+ }
1411
+
1412
+ // Has Server-side tools - we need to execute them and continue
1413
+ // Override toolCalls to only include Server-side tools for server execution
1414
+ toolCalls = serverSideToolCalls;
1415
+
1416
+ logger.info(
1417
+ {
1418
+ sessionId: session?.id ?? null,
1419
+ serverToolCount: serverSideToolCalls.length,
1380
1420
  },
1381
- steps,
1382
- durationMs: Date.now() - start,
1383
- terminationReason: "tool_use",
1384
- };
1421
+ "Executing server-side tools in hybrid mode"
1422
+ );
1423
+ } else if (executionMode === "passthrough" || executionMode === "client") {
1424
+ // Only Server-side tools, no Client-side tools - execute all server-side
1425
+ logger.info(
1426
+ {
1427
+ sessionId: session?.id ?? null,
1428
+ serverToolCount: serverSideToolCalls.length,
1429
+ },
1430
+ "All tools are server-side tools - executing server-side"
1431
+ );
1385
1432
  }
1386
1433
 
1387
1434
  logger.debug(
@@ -1413,8 +1460,127 @@ async function runAgentLoop({
1413
1460
  toolCallsWithPolicy.push({ call, decision });
1414
1461
  }
1415
1462
 
1416
- // Now process results (still sequential for message ordering)
1417
- for (const { call, decision } of toolCallsWithPolicy) {
1463
+ // Identify Task tool calls for parallel execution
1464
+ const taskCalls = [];
1465
+ const nonTaskCalls = [];
1466
+
1467
+ for (const item of toolCallsWithPolicy) {
1468
+ const toolName = (item.call.function?.name ?? item.call.name ?? "").toLowerCase();
1469
+ if (toolName === "task" && item.decision.allowed) {
1470
+ taskCalls.push(item);
1471
+ } else {
1472
+ nonTaskCalls.push(item);
1473
+ }
1474
+ }
1475
+
1476
+ // Execute Task tools in parallel if multiple exist
1477
+ if (taskCalls.length > 1) {
1478
+ logger.info({
1479
+ taskCount: taskCalls.length,
1480
+ sessionId: session?.id
1481
+ }, "Executing multiple Task tools in parallel");
1482
+
1483
+ try {
1484
+ // Execute all Task tools in parallel
1485
+ const taskExecutions = await Promise.all(
1486
+ taskCalls.map(({ call }) => executeToolCall(call, {
1487
+ session,
1488
+ requestMessages: cleanPayload.messages,
1489
+ }))
1490
+ );
1491
+
1492
+ // Process results and add to messages
1493
+ taskExecutions.forEach((execution, index) => {
1494
+ const call = taskCalls[index].call;
1495
+ toolCallsExecuted += 1;
1496
+
1497
+ let toolMessage;
1498
+ if (providerType === "azure-anthropic") {
1499
+ const parsedContent = parseExecutionContent(execution.content);
1500
+ const serialisedContent =
1501
+ typeof parsedContent === "string" || parsedContent === null
1502
+ ? parsedContent ?? ""
1503
+ : JSON.stringify(parsedContent);
1504
+
1505
+ toolMessage = {
1506
+ role: "user",
1507
+ content: [
1508
+ {
1509
+ type: "tool_result",
1510
+ tool_use_id: call.id ?? execution.id,
1511
+ content: serialisedContent,
1512
+ is_error: execution.ok === false,
1513
+ },
1514
+ ],
1515
+ };
1516
+
1517
+ toolCallNames.set(
1518
+ call.id ?? execution.id,
1519
+ normaliseToolIdentifier(
1520
+ call.function?.name ?? call.name ?? execution.name ?? "tool",
1521
+ ),
1522
+ );
1523
+ } else {
1524
+ toolMessage = {
1525
+ role: "tool",
1526
+ tool_call_id: execution.id,
1527
+ name: execution.name,
1528
+ content: execution.content,
1529
+ };
1530
+ }
1531
+
1532
+ cleanPayload.messages.push(toolMessage);
1533
+
1534
+ // Convert to Anthropic format for session storage
1535
+ let sessionToolResultContent;
1536
+ if (providerType === "azure-anthropic") {
1537
+ sessionToolResultContent = toolMessage.content;
1538
+ } else {
1539
+ sessionToolResultContent = [
1540
+ {
1541
+ type: "tool_result",
1542
+ tool_use_id: toolMessage.tool_call_id,
1543
+ content: toolMessage.content,
1544
+ is_error: execution.ok === false,
1545
+ },
1546
+ ];
1547
+ }
1548
+
1549
+ appendTurnToSession(session, {
1550
+ role: "tool",
1551
+ type: "tool_result",
1552
+ status: execution.status,
1553
+ content: sessionToolResultContent,
1554
+ metadata: {
1555
+ tool: execution.name,
1556
+ ok: execution.ok,
1557
+ parallel: true,
1558
+ parallelIndex: index,
1559
+ totalParallel: taskExecutions.length
1560
+ },
1561
+ });
1562
+ });
1563
+
1564
+ logger.info({
1565
+ completedTasks: taskExecutions.length,
1566
+ sessionId: session?.id
1567
+ }, "Completed parallel Task execution");
1568
+ } catch (error) {
1569
+ logger.error({
1570
+ error: error.message,
1571
+ taskCount: taskCalls.length
1572
+ }, "Error in parallel Task execution");
1573
+
1574
+ // Fall back to sequential execution on error
1575
+ taskCalls.forEach(item => nonTaskCalls.push(item));
1576
+ }
1577
+ } else if (taskCalls.length === 1) {
1578
+ // Single Task tool - add back to non-task calls for normal processing
1579
+ nonTaskCalls.push(...taskCalls);
1580
+ }
1581
+
1582
+ // Now process results (sequential for non-Task tools or blocked tools)
1583
+ for (const { call, decision } of nonTaskCalls) {
1418
1584
 
1419
1585
  if (!decision.allowed) {
1420
1586
  policy.logPolicyDecision(decision, {
@@ -1653,6 +1819,57 @@ async function runAgentLoop({
1653
1819
  databricksResponse.json,
1654
1820
  requestedModel,
1655
1821
  );
1822
+ anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
1823
+ } else if (actualProvider === "azure-openai") {
1824
+ const { convertOpenRouterResponseToAnthropic } = require("../clients/openrouter-utils");
1825
+
1826
+ // Validate Azure OpenAI response has choices array before conversion
1827
+ if (!databricksResponse.json?.choices?.length) {
1828
+ logger.warn({
1829
+ json: databricksResponse.json,
1830
+ status: databricksResponse.status
1831
+ }, "Azure OpenAI response missing choices array");
1832
+
1833
+ appendTurnToSession(session, {
1834
+ role: "assistant",
1835
+ type: "error",
1836
+ status: databricksResponse.status,
1837
+ content: databricksResponse.json,
1838
+ metadata: { termination: "malformed_response" },
1839
+ });
1840
+
1841
+ const response = buildErrorResponse(databricksResponse);
1842
+ return {
1843
+ response,
1844
+ steps,
1845
+ durationMs: Date.now() - start,
1846
+ terminationReason: response.terminationReason,
1847
+ };
1848
+ }
1849
+
1850
+ // Log Azure OpenAI raw response
1851
+ logger.info({
1852
+ hasChoices: !!databricksResponse.json?.choices,
1853
+ choiceCount: databricksResponse.json?.choices?.length || 0,
1854
+ firstChoice: databricksResponse.json?.choices?.[0],
1855
+ hasToolCalls: !!databricksResponse.json?.choices?.[0]?.message?.tool_calls,
1856
+ toolCallCount: databricksResponse.json?.choices?.[0]?.message?.tool_calls?.length || 0,
1857
+ finishReason: databricksResponse.json?.choices?.[0]?.finish_reason
1858
+ }, "=== AZURE OPENAI RAW RESPONSE ===");
1859
+
1860
+ // Convert OpenAI format to Anthropic format (reuse OpenRouter utility)
1861
+ anthropicPayload = convertOpenRouterResponseToAnthropic(
1862
+ databricksResponse.json,
1863
+ requestedModel,
1864
+ );
1865
+
1866
+ logger.info({
1867
+ contentBlocks: anthropicPayload.content?.length || 0,
1868
+ contentTypes: anthropicPayload.content?.map(c => c.type) || [],
1869
+ stopReason: anthropicPayload.stop_reason,
1870
+ hasToolUse: anthropicPayload.content?.some(c => c.type === 'tool_use')
1871
+ }, "=== CONVERTED ANTHROPIC RESPONSE ===");
1872
+
1656
1873
  anthropicPayload.content = policy.sanitiseContent(anthropicPayload.content);
1657
1874
  } else {
1658
1875
  anthropicPayload = toAnthropicResponse(
package/src/server.js CHANGED
@@ -26,6 +26,7 @@ const { registerGitTools } = require("./tools/git");
26
26
  const { registerTaskTools } = require("./tools/tasks");
27
27
  const { registerTestTools } = require("./tools/tests");
28
28
  const { registerMcpTools } = require("./tools/mcp");
29
+ const { registerAgentTaskTool } = require("./tools/agent-task");
29
30
 
30
31
  initialiseMcp();
31
32
  registerStubTools();
@@ -38,6 +39,7 @@ registerGitTools();
38
39
  registerTaskTools();
39
40
  registerTestTools();
40
41
  registerMcpTools();
42
+ registerAgentTaskTool();
41
43
 
42
44
  function createApp() {
43
45
  const app = express();
@@ -0,0 +1,96 @@
1
+ const { registerTool } = require(".");
2
+ const { spawnAgent, autoSelectAgent } = require("../agents");
3
+ const logger = require("../logger");
4
+
5
+ function registerAgentTaskTool() {
6
+ registerTool(
7
+ "Task",
8
+ async ({ args = {} }, context = {}) => {
9
+ let subagentType = args.subagent_type || args.type;
10
+ const prompt = args.prompt;
11
+ const description = args.description || "Agent task";
12
+
13
+ if (!prompt) {
14
+ return {
15
+ ok: false,
16
+ status: 400,
17
+ content: JSON.stringify({
18
+ error: "prompt is required"
19
+ }, null, 2)
20
+ };
21
+ }
22
+
23
+ // Auto-select agent if not specified
24
+ if (!subagentType) {
25
+ const selected = autoSelectAgent(prompt);
26
+ if (selected) {
27
+ subagentType = selected.name;
28
+ logger.info({
29
+ selectedAgent: subagentType,
30
+ prompt: prompt.slice(0, 50)
31
+ }, "Auto-selected subagent");
32
+ } else {
33
+ subagentType = "Explore"; // Default fallback
34
+ }
35
+ }
36
+
37
+ logger.info({
38
+ subagentType,
39
+ prompt: prompt.slice(0, 100),
40
+ sessionId: context.sessionId
41
+ }, "Task tool: spawning subagent");
42
+
43
+ try {
44
+ const result = await spawnAgent(subagentType, prompt, {
45
+ sessionId: context.sessionId,
46
+ mainContext: context.mainContext // Pass minimal context
47
+ });
48
+
49
+ if (result.success) {
50
+ return {
51
+ ok: true,
52
+ status: 200,
53
+ content: result.result,
54
+ metadata: {
55
+ agentType: subagentType,
56
+ agentId: result.stats.agentId,
57
+ steps: result.stats.steps,
58
+ durationMs: result.stats.durationMs
59
+ }
60
+ };
61
+ } else {
62
+ return {
63
+ ok: false,
64
+ status: 500,
65
+ content: JSON.stringify({
66
+ error: "Subagent execution failed",
67
+ message: result.error
68
+ }, null, 2)
69
+ };
70
+ }
71
+
72
+ } catch (error) {
73
+ logger.error({
74
+ error: error.message,
75
+ subagentType
76
+ }, "Task tool: subagent error");
77
+
78
+ return {
79
+ ok: false,
80
+ status: 500,
81
+ content: JSON.stringify({
82
+ error: "Subagent error",
83
+ message: error.message
84
+ }, null, 2)
85
+ };
86
+ }
87
+ },
88
+ { category: "agents" }
89
+ );
90
+
91
+ logger.info("Task tool registered");
92
+ }
93
+
94
+ module.exports = {
95
+ registerAgentTaskTool
96
+ };