lemura 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +14 -2
- package/dist/adapters/index.d.mts +2 -3
- package/dist/adapters/index.d.ts +2 -3
- package/dist/adapters/index.js +5 -5
- package/dist/adapters/index.js.map +1 -1
- package/dist/adapters/index.mjs +5 -5
- package/dist/adapters/index.mjs.map +1 -1
- package/dist/agent-D6uhF-CZ.d.mts +248 -0
- package/dist/agent-DxRd93wl.d.ts +248 -0
- package/dist/context/index.d.mts +2 -3
- package/dist/context/index.d.ts +2 -3
- package/dist/context/index.js.map +1 -1
- package/dist/context/index.mjs.map +1 -1
- package/dist/index.d.mts +257 -9
- package/dist/index.d.ts +257 -9
- package/dist/index.js +1599 -217
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1592 -218
- package/dist/index.mjs.map +1 -1
- package/dist/{adapters-BnG0LEYD.d.mts → storage-CG3nTa6o.d.mts} +127 -2
- package/dist/{adapters-BSkhv5ac.d.ts → storage-DBt_q0wO.d.ts} +127 -2
- package/dist/tools/index.d.mts +125 -5
- package/dist/tools/index.d.ts +125 -5
- package/dist/tools/index.js +278 -7
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/index.mjs +277 -8
- package/dist/tools/index.mjs.map +1 -1
- package/dist/types/index.d.mts +18 -69
- package/dist/types/index.d.ts +18 -69
- package/dist/types/index.js +21 -0
- package/dist/types/index.js.map +1 -1
- package/dist/types/index.mjs +19 -1
- package/dist/types/index.mjs.map +1 -1
- package/package.json +1 -1
- package/dist/storage-BGu4Loao.d.ts +0 -121
- package/dist/storage-DMcliVVj.d.mts +0 -121
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from 'crypto';
|
|
2
|
+
import { spawn } from 'child_process';
|
|
2
3
|
|
|
3
4
|
// src/types/errors.ts
|
|
4
5
|
var LemuraError = class extends Error {
|
|
@@ -60,6 +61,24 @@ var LemuraToolTimeoutError = class extends LemuraError {
|
|
|
60
61
|
this.name = "LemuraToolTimeoutError";
|
|
61
62
|
}
|
|
62
63
|
};
|
|
64
|
+
var LemuraMCPError = class extends LemuraError {
|
|
65
|
+
constructor(message, code = "MCP_ERROR", problem, hints = []) {
|
|
66
|
+
super(message, code, problem, hints);
|
|
67
|
+
this.name = "LemuraMCPError";
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
var LemuraMCPConnectionError = class extends LemuraMCPError {
|
|
71
|
+
constructor(message, problem, hints = []) {
|
|
72
|
+
super(message, "MCP_CONNECTION_FAILED", problem, hints);
|
|
73
|
+
this.name = "LemuraMCPConnectionError";
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
var LemuraMCPTimeoutError = class extends LemuraMCPError {
|
|
77
|
+
constructor(message) {
|
|
78
|
+
super(message, "MCP_TOOL_TIMEOUT");
|
|
79
|
+
this.name = "LemuraMCPTimeoutError";
|
|
80
|
+
}
|
|
81
|
+
};
|
|
63
82
|
|
|
64
83
|
// src/types/logger.ts
|
|
65
84
|
var LogLevel = /* @__PURE__ */ ((LogLevel2) => {
|
|
@@ -74,7 +93,7 @@ var LogLevel = /* @__PURE__ */ ((LogLevel2) => {
|
|
|
74
93
|
// src/adapters/OpenAICompatibleAdapter.ts
|
|
75
94
|
var OpenAICompatibleAdapter = class {
|
|
76
95
|
name = "openai_compatible";
|
|
77
|
-
version = "1.
|
|
96
|
+
version = "1.2.0";
|
|
78
97
|
baseUrl;
|
|
79
98
|
apiKey;
|
|
80
99
|
defaultModel;
|
|
@@ -323,7 +342,7 @@ var OpenAICompatibleAdapter = class {
|
|
|
323
342
|
const blob = new Blob([bytes], { type: request.mimeType });
|
|
324
343
|
const formData = new FormData();
|
|
325
344
|
formData.append("file", blob, "audio.webm");
|
|
326
|
-
formData.append("model", "whisper-1");
|
|
345
|
+
formData.append("model", request.model || "whisper-1");
|
|
327
346
|
if (request.language) formData.append("language", request.language);
|
|
328
347
|
const response = await this.fetchWithRetry(`${this.baseUrl}/audio/transcriptions`, {
|
|
329
348
|
method: "POST",
|
|
@@ -344,7 +363,7 @@ var OpenAICompatibleAdapter = class {
|
|
|
344
363
|
const response = await this.fetchWithRetry(`${this.baseUrl}/audio/speech`, {
|
|
345
364
|
method: "POST",
|
|
346
365
|
body: JSON.stringify({
|
|
347
|
-
model: "tts-1",
|
|
366
|
+
model: request.model || "tts-1",
|
|
348
367
|
input: request.text,
|
|
349
368
|
voice: request.voiceId || "alloy",
|
|
350
369
|
response_format: request.format || "mp3"
|
|
@@ -367,7 +386,7 @@ var OpenAICompatibleAdapter = class {
|
|
|
367
386
|
}
|
|
368
387
|
async describeImage(request) {
|
|
369
388
|
const payload = {
|
|
370
|
-
model: this.defaultModel,
|
|
389
|
+
model: request.model || this.defaultModel,
|
|
371
390
|
messages: [
|
|
372
391
|
{
|
|
373
392
|
role: "user",
|
|
@@ -399,7 +418,7 @@ var OpenAICompatibleAdapter = class {
|
|
|
399
418
|
method: "POST",
|
|
400
419
|
body: JSON.stringify({
|
|
401
420
|
prompt: request.prompt,
|
|
402
|
-
model: "dall-e-3",
|
|
421
|
+
model: request.model || "dall-e-3",
|
|
403
422
|
n: 1,
|
|
404
423
|
size: request.dimensions || "1024x1024"
|
|
405
424
|
})
|
|
@@ -697,40 +716,336 @@ var InMemoryStorageAdapter = class {
|
|
|
697
716
|
}
|
|
698
717
|
};
|
|
699
718
|
|
|
719
|
+
// src/tools/SchemaValidator.ts
|
|
720
|
+
function typeOf(value) {
|
|
721
|
+
if (value === null) return "null";
|
|
722
|
+
if (Array.isArray(value)) return "array";
|
|
723
|
+
return typeof value;
|
|
724
|
+
}
|
|
725
|
+
function matchesType(value, requiredType) {
|
|
726
|
+
const t = typeOf(value);
|
|
727
|
+
if (requiredType === "integer") return t === "number" && Number.isInteger(value);
|
|
728
|
+
return t === requiredType;
|
|
729
|
+
}
|
|
730
|
+
function validateJsonSchema(value, schema, path = "") {
|
|
731
|
+
const errors = [];
|
|
732
|
+
if (schema["type"] !== void 0) {
|
|
733
|
+
const expectedType = schema["type"];
|
|
734
|
+
const types = Array.isArray(expectedType) ? expectedType : [expectedType];
|
|
735
|
+
if (!types.some((t) => matchesType(value, t))) {
|
|
736
|
+
errors.push({
|
|
737
|
+
path,
|
|
738
|
+
message: `Expected type '${types.join("|")}', got '${typeOf(value)}'`
|
|
739
|
+
});
|
|
740
|
+
return errors;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
if (schema["enum"] !== void 0) {
|
|
744
|
+
const allowed = schema["enum"];
|
|
745
|
+
if (!allowed.some((a) => JSON.stringify(a) === JSON.stringify(value))) {
|
|
746
|
+
errors.push({ path, message: `Value must be one of: ${JSON.stringify(allowed)}` });
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
if ("const" in schema) {
|
|
750
|
+
if (JSON.stringify(schema["const"]) !== JSON.stringify(value)) {
|
|
751
|
+
errors.push({ path, message: `Value must equal ${JSON.stringify(schema["const"])}` });
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (typeof value === "string") {
|
|
755
|
+
if (schema["minLength"] !== void 0 && value.length < schema["minLength"]) {
|
|
756
|
+
errors.push({ path, message: `String too short (min ${schema["minLength"]}, got ${value.length})` });
|
|
757
|
+
}
|
|
758
|
+
if (schema["maxLength"] !== void 0 && value.length > schema["maxLength"]) {
|
|
759
|
+
errors.push({ path, message: `String too long (max ${schema["maxLength"]}, got ${value.length})` });
|
|
760
|
+
}
|
|
761
|
+
if (schema["pattern"] !== void 0) {
|
|
762
|
+
try {
|
|
763
|
+
const re = new RegExp(schema["pattern"]);
|
|
764
|
+
if (!re.test(value)) {
|
|
765
|
+
errors.push({ path, message: `String does not match pattern /${schema["pattern"]}/` });
|
|
766
|
+
}
|
|
767
|
+
} catch {
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
if (typeof value === "number") {
|
|
772
|
+
if (schema["minimum"] !== void 0 && value < schema["minimum"]) {
|
|
773
|
+
errors.push({ path, message: `Value ${value} is less than minimum ${schema["minimum"]}` });
|
|
774
|
+
}
|
|
775
|
+
if (schema["maximum"] !== void 0 && value > schema["maximum"]) {
|
|
776
|
+
errors.push({ path, message: `Value ${value} exceeds maximum ${schema["maximum"]}` });
|
|
777
|
+
}
|
|
778
|
+
if (schema["exclusiveMinimum"] !== void 0 && value <= schema["exclusiveMinimum"]) {
|
|
779
|
+
errors.push({ path, message: `Value ${value} must be > ${schema["exclusiveMinimum"]}` });
|
|
780
|
+
}
|
|
781
|
+
if (schema["exclusiveMaximum"] !== void 0 && value >= schema["exclusiveMaximum"]) {
|
|
782
|
+
errors.push({ path, message: `Value ${value} must be < ${schema["exclusiveMaximum"]}` });
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
if (Array.isArray(value)) {
|
|
786
|
+
if (schema["minItems"] !== void 0 && value.length < schema["minItems"]) {
|
|
787
|
+
errors.push({ path, message: `Array too short (min ${schema["minItems"]} items)` });
|
|
788
|
+
}
|
|
789
|
+
if (schema["maxItems"] !== void 0 && value.length > schema["maxItems"]) {
|
|
790
|
+
errors.push({ path, message: `Array too long (max ${schema["maxItems"]} items)` });
|
|
791
|
+
}
|
|
792
|
+
if (schema["items"] !== void 0) {
|
|
793
|
+
const itemSchema = schema["items"];
|
|
794
|
+
value.forEach((item, idx) => {
|
|
795
|
+
const childErrors = validateJsonSchema(item, itemSchema, `${path}[${idx}]`);
|
|
796
|
+
errors.push(...childErrors);
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
801
|
+
const obj = value;
|
|
802
|
+
const props = schema["properties"] || {};
|
|
803
|
+
const required = schema["required"] || [];
|
|
804
|
+
for (const key of required) {
|
|
805
|
+
if (!(key in obj)) {
|
|
806
|
+
errors.push({ path: path ? `${path}.${key}` : key, message: `Required property '${key}' is missing` });
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
for (const [key, propSchema] of Object.entries(props)) {
|
|
810
|
+
if (key in obj) {
|
|
811
|
+
const childErrors = validateJsonSchema(obj[key], propSchema, path ? `${path}.${key}` : key);
|
|
812
|
+
errors.push(...childErrors);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
if (schema["additionalProperties"] === false) {
|
|
816
|
+
for (const key of Object.keys(obj)) {
|
|
817
|
+
if (!(key in props)) {
|
|
818
|
+
errors.push({ path: path ? `${path}.${key}` : key, message: `Additional property '${key}' is not allowed` });
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
} else if (schema["additionalProperties"] !== void 0 && typeof schema["additionalProperties"] === "object") {
|
|
822
|
+
const addlSchema = schema["additionalProperties"];
|
|
823
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
824
|
+
if (!(key in props)) {
|
|
825
|
+
const childErrors = validateJsonSchema(val, addlSchema, path ? `${path}.${key}` : key);
|
|
826
|
+
errors.push(...childErrors);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
if (schema["minProperties"] !== void 0 && Object.keys(obj).length < schema["minProperties"]) {
|
|
831
|
+
errors.push({ path, message: `Object has fewer than ${schema["minProperties"]} properties` });
|
|
832
|
+
}
|
|
833
|
+
if (schema["maxProperties"] !== void 0 && Object.keys(obj).length > schema["maxProperties"]) {
|
|
834
|
+
errors.push({ path, message: `Object has more than ${schema["maxProperties"]} properties` });
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
if (schema["allOf"] !== void 0) {
|
|
838
|
+
for (const subSchema of schema["allOf"]) {
|
|
839
|
+
errors.push(...validateJsonSchema(value, subSchema, path));
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
if (schema["anyOf"] !== void 0) {
|
|
843
|
+
const subSchemas = schema["anyOf"];
|
|
844
|
+
const anyPassed = subSchemas.some((s) => validateJsonSchema(value, s, path).length === 0);
|
|
845
|
+
if (!anyPassed) {
|
|
846
|
+
errors.push({ path, message: "Value does not match any of the anyOf schemas" });
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
if (schema["oneOf"] !== void 0) {
|
|
850
|
+
const subSchemas = schema["oneOf"];
|
|
851
|
+
const passing = subSchemas.filter((s) => validateJsonSchema(value, s, path).length === 0);
|
|
852
|
+
if (passing.length !== 1) {
|
|
853
|
+
errors.push({
|
|
854
|
+
path,
|
|
855
|
+
message: `Value must match exactly one of the oneOf schemas (matched ${passing.length})`
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (schema["not"] !== void 0) {
|
|
860
|
+
const subErrors = validateJsonSchema(value, schema["not"], path);
|
|
861
|
+
if (subErrors.length === 0) {
|
|
862
|
+
errors.push({ path, message: "Value must NOT match the not schema" });
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return errors;
|
|
866
|
+
}
|
|
867
|
+
|
|
700
868
|
// src/tools/ToolRegistry.ts
|
|
701
869
|
var ToolRegistry = class {
|
|
702
870
|
tools = /* @__PURE__ */ new Map();
|
|
703
|
-
|
|
871
|
+
defaultTimeoutMs;
|
|
872
|
+
constructor(initialTools = [], options = {}) {
|
|
873
|
+
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 3e4;
|
|
704
874
|
for (const tool of initialTools) {
|
|
705
875
|
this.register(tool);
|
|
706
876
|
}
|
|
707
877
|
}
|
|
878
|
+
/**
|
|
879
|
+
* Registers a tool. Throws if a tool with the same name is already registered.
|
|
880
|
+
*
|
|
881
|
+
* @param tool - The tool definition to register
|
|
882
|
+
* @throws {LemuraError} If `tool.name` is already taken
|
|
883
|
+
*/
|
|
708
884
|
register(tool) {
|
|
709
885
|
if (this.tools.has(tool.name)) {
|
|
710
|
-
throw new
|
|
886
|
+
throw new LemuraToolNotFoundError(`Tool '${tool.name}' is already registered. Use a unique name.`);
|
|
711
887
|
}
|
|
712
888
|
this.tools.set(tool.name, tool);
|
|
713
889
|
}
|
|
890
|
+
/**
|
|
891
|
+
* Removes a registered tool by name.
|
|
892
|
+
*
|
|
893
|
+
* @param name - The tool name to unregister
|
|
894
|
+
*/
|
|
895
|
+
unregister(name) {
|
|
896
|
+
return this.tools.delete(name);
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* Returns the tool definition for `name`, or `undefined` if not found.
|
|
900
|
+
*
|
|
901
|
+
* @param name - Tool name to look up
|
|
902
|
+
*/
|
|
714
903
|
get(name) {
|
|
715
904
|
return this.tools.get(name);
|
|
716
905
|
}
|
|
906
|
+
/**
|
|
907
|
+
* Returns all registered tool definitions.
|
|
908
|
+
*/
|
|
717
909
|
getAll() {
|
|
718
910
|
return Array.from(this.tools.values());
|
|
719
911
|
}
|
|
912
|
+
/**
|
|
913
|
+
* Executes a single tool call with schema validation and timeout enforcement.
|
|
914
|
+
*
|
|
915
|
+
* @param name - The tool name to execute
|
|
916
|
+
* @param params - The raw parameter object (validated against the tool's JSON Schema)
|
|
917
|
+
* @param context - Execution context (session, logger, adapters)
|
|
918
|
+
* @returns The tool's result
|
|
919
|
+
* @throws {LemuraToolNotFoundError} If the tool is not registered
|
|
920
|
+
* @throws {LemuraToolValidationError} If params fail JSON Schema validation
|
|
921
|
+
* @throws {LemuraToolTimeoutError} If execution exceeds the configured timeout
|
|
922
|
+
*/
|
|
720
923
|
async execute(name, params, context) {
|
|
721
924
|
const tool = this.tools.get(name);
|
|
722
925
|
if (!tool) {
|
|
723
|
-
throw new LemuraToolNotFoundError(`Tool '${name}' not found.`);
|
|
926
|
+
throw new LemuraToolNotFoundError(`Tool '${name}' not found in registry.`);
|
|
927
|
+
}
|
|
928
|
+
if (tool.parameters && typeof tool.parameters === "object") {
|
|
929
|
+
const schemaErrors = validateJsonSchema(params, tool.parameters);
|
|
930
|
+
if (schemaErrors.length > 0) {
|
|
931
|
+
const msg = schemaErrors.map((e) => e.path ? `[${e.path}] ${e.message}` : e.message).join("; ");
|
|
932
|
+
throw new LemuraToolValidationError(
|
|
933
|
+
`Tool '${name}' parameter validation failed: ${msg}`
|
|
934
|
+
);
|
|
935
|
+
}
|
|
724
936
|
}
|
|
937
|
+
const toolTimeoutMs = tool["timeoutMs"];
|
|
938
|
+
const timeoutMs = typeof toolTimeoutMs === "number" ? toolTimeoutMs : this.defaultTimeoutMs;
|
|
939
|
+
const executionPromise = tool.execute(params, context);
|
|
940
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
941
|
+
const id = setTimeout(() => {
|
|
942
|
+
clearTimeout(id);
|
|
943
|
+
reject(new LemuraToolTimeoutError(
|
|
944
|
+
`Tool '${name}' timed out after ${timeoutMs}ms`
|
|
945
|
+
));
|
|
946
|
+
}, timeoutMs);
|
|
947
|
+
});
|
|
725
948
|
try {
|
|
726
|
-
|
|
727
|
-
return result;
|
|
949
|
+
return await Promise.race([executionPromise, timeoutPromise]);
|
|
728
950
|
} catch (err) {
|
|
729
|
-
if (err instanceof LemuraToolValidationError) {
|
|
951
|
+
if (err instanceof LemuraToolValidationError || err instanceof LemuraToolTimeoutError) {
|
|
730
952
|
throw err;
|
|
731
953
|
}
|
|
732
|
-
|
|
954
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
955
|
+
throw new LemuraToolValidationError(
|
|
956
|
+
`Tool '${name}' execution failed: ${message}`
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Executes multiple tool calls in parallel.
|
|
962
|
+
* All calls are issued simultaneously; results are returned in the same order as `calls`.
|
|
963
|
+
* Individual errors are captured per-call without aborting the others.
|
|
964
|
+
*
|
|
965
|
+
* @param calls - Array of `{ name, params }` objects
|
|
966
|
+
* @param context - Shared execution context
|
|
967
|
+
* @returns Array of `{ toolCallId, result?, error? }` in input order
|
|
968
|
+
*
|
|
969
|
+
* @example
|
|
970
|
+
* const results = await registry.executeParallel(
|
|
971
|
+
* [{ name: 'search_web', params: { query: 'foo' } }],
|
|
972
|
+
* context
|
|
973
|
+
* );
|
|
974
|
+
*/
|
|
975
|
+
async executeParallel(calls, context) {
|
|
976
|
+
return Promise.all(
|
|
977
|
+
calls.map(async (call) => {
|
|
978
|
+
try {
|
|
979
|
+
const result = await this.execute(call.name, call.params, context);
|
|
980
|
+
return { id: call.id, result };
|
|
981
|
+
} catch (err) {
|
|
982
|
+
return { id: call.id, error: err instanceof Error ? err : new Error(String(err)) };
|
|
983
|
+
}
|
|
984
|
+
})
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
};
|
|
988
|
+
|
|
989
|
+
// src/tools/ToolFirewall.ts
|
|
990
|
+
function matchesRule(pattern, value, logger) {
|
|
991
|
+
if (!pattern) return true;
|
|
992
|
+
try {
|
|
993
|
+
const re = new RegExp(pattern);
|
|
994
|
+
return re.test(value);
|
|
995
|
+
} catch (err) {
|
|
996
|
+
if (logger) logger.warn(`Invalid firewall regex: ${pattern}`, { error: err?.message });
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
function evaluateToolFirewall(config, toolName, argsJson, logger) {
|
|
1001
|
+
const defaultDecision = config?.defaultDecision || "ask";
|
|
1002
|
+
const rules = config?.rules || [];
|
|
1003
|
+
for (const rule of rules) {
|
|
1004
|
+
const nameOk = matchesRule(rule.name, toolName, logger);
|
|
1005
|
+
const argsOk = matchesRule(rule.arguments, argsJson, logger);
|
|
1006
|
+
if (nameOk && argsOk) {
|
|
1007
|
+
return {
|
|
1008
|
+
decision: rule.decision,
|
|
1009
|
+
reason: rule.reason || "Matched firewall rule"
|
|
1010
|
+
};
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
return {
|
|
1014
|
+
decision: defaultDecision,
|
|
1015
|
+
reason: "Default firewall decision"
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// src/media/MediaBridge.ts
|
|
1020
|
+
var MediaBridge = class {
|
|
1021
|
+
adapter;
|
|
1022
|
+
constructor(adapter) {
|
|
1023
|
+
this.adapter = adapter;
|
|
1024
|
+
}
|
|
1025
|
+
transcribe(request) {
|
|
1026
|
+
return this.adapter.transcribe(request);
|
|
1027
|
+
}
|
|
1028
|
+
synthesize(request) {
|
|
1029
|
+
return this.adapter.synthesize(request);
|
|
1030
|
+
}
|
|
1031
|
+
async synthesizeToArray(request) {
|
|
1032
|
+
const chunks = [];
|
|
1033
|
+
for await (const chunk of this.adapter.synthesize(request)) {
|
|
1034
|
+
chunks.push(chunk);
|
|
733
1035
|
}
|
|
1036
|
+
return chunks;
|
|
1037
|
+
}
|
|
1038
|
+
describeImage(request) {
|
|
1039
|
+
return this.adapter.describeImage(request);
|
|
1040
|
+
}
|
|
1041
|
+
generateImage(request) {
|
|
1042
|
+
return this.adapter.generateImage(request);
|
|
1043
|
+
}
|
|
1044
|
+
getModelInfo() {
|
|
1045
|
+
return this.adapter.getModelInfo();
|
|
1046
|
+
}
|
|
1047
|
+
supportsVision() {
|
|
1048
|
+
return this.adapter.getModelInfo().supportsVision;
|
|
734
1049
|
}
|
|
735
1050
|
};
|
|
736
1051
|
|
|
@@ -1029,146 +1344,1120 @@ var summarizeSandwichTool = {
|
|
|
1029
1344
|
}
|
|
1030
1345
|
};
|
|
1031
1346
|
|
|
1032
|
-
// src/
|
|
1033
|
-
var
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
adapter;
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1347
|
+
// src/tools/builtin/media.ts
|
|
1348
|
+
var defaultPrefix = "media_";
|
|
1349
|
+
function requireAdapter(context) {
|
|
1350
|
+
if (!context.adapter) {
|
|
1351
|
+
throw new LemuraAdapterError("Provider adapter is missing from tool context", "ADAPTER_MISSING");
|
|
1352
|
+
}
|
|
1353
|
+
return context.adapter;
|
|
1354
|
+
}
|
|
1355
|
+
function createMediaTools(prefix = defaultPrefix) {
|
|
1356
|
+
const p = prefix || defaultPrefix;
|
|
1357
|
+
const transcribeTool = {
|
|
1358
|
+
name: `${p}transcribe`,
|
|
1359
|
+
description: "Transcribe audio from base64 into text.",
|
|
1360
|
+
parameters: {
|
|
1361
|
+
type: "object",
|
|
1362
|
+
properties: {
|
|
1363
|
+
audioBase64: { type: "string", description: "Base64-encoded audio payload." },
|
|
1364
|
+
mimeType: { type: "string", description: "Audio MIME type like audio/wav or audio/mpeg." },
|
|
1365
|
+
language: { type: "string", description: "Optional language hint like en, fr." },
|
|
1366
|
+
model: { type: "string", description: "Optional model override for ASR." }
|
|
1367
|
+
},
|
|
1368
|
+
required: ["audioBase64", "mimeType"]
|
|
1369
|
+
},
|
|
1370
|
+
async execute(params, context) {
|
|
1371
|
+
const adapter = requireAdapter(context);
|
|
1372
|
+
const result = await adapter.transcribe({
|
|
1373
|
+
audioBase64: params.audioBase64,
|
|
1374
|
+
mimeType: params.mimeType,
|
|
1375
|
+
language: params.language,
|
|
1376
|
+
model: params.model
|
|
1377
|
+
});
|
|
1378
|
+
return result;
|
|
1051
1379
|
}
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1380
|
+
};
|
|
1381
|
+
const synthesizeTool = {
|
|
1382
|
+
name: `${p}synthesize`,
|
|
1383
|
+
description: "Synthesize text to speech and return audio chunks (base64).",
|
|
1384
|
+
parameters: {
|
|
1385
|
+
type: "object",
|
|
1386
|
+
properties: {
|
|
1387
|
+
text: { type: "string", description: "Text to synthesize." },
|
|
1388
|
+
voiceId: { type: "string", description: "Voice identifier." },
|
|
1389
|
+
format: { type: "string", enum: ["mp3", "wav", "pcm"] },
|
|
1390
|
+
model: { type: "string", description: "Optional model override for TTS." }
|
|
1391
|
+
},
|
|
1392
|
+
required: ["text", "voiceId", "format"]
|
|
1393
|
+
},
|
|
1394
|
+
async execute(params, context) {
|
|
1395
|
+
const adapter = requireAdapter(context);
|
|
1396
|
+
const chunks = [];
|
|
1397
|
+
for await (const chunk of adapter.synthesize({
|
|
1398
|
+
text: params.text,
|
|
1399
|
+
voiceId: params.voiceId,
|
|
1400
|
+
format: params.format,
|
|
1401
|
+
model: params.model
|
|
1402
|
+
})) {
|
|
1403
|
+
chunks.push(chunk);
|
|
1404
|
+
}
|
|
1405
|
+
return { chunks };
|
|
1061
1406
|
}
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1407
|
+
};
|
|
1408
|
+
const visionTool = {
|
|
1409
|
+
name: `${p}describe_image`,
|
|
1410
|
+
description: "Describe an image from base64 and optional prompt.",
|
|
1411
|
+
parameters: {
|
|
1412
|
+
type: "object",
|
|
1413
|
+
properties: {
|
|
1414
|
+
imageBase64: { type: "string", description: "Base64-encoded image payload." },
|
|
1415
|
+
prompt: { type: "string", description: "Optional instruction or focus for description." },
|
|
1416
|
+
model: { type: "string", description: "Optional model override for vision." }
|
|
1417
|
+
},
|
|
1418
|
+
required: ["imageBase64"]
|
|
1419
|
+
},
|
|
1420
|
+
async execute(params, context) {
|
|
1421
|
+
const adapter = requireAdapter(context);
|
|
1422
|
+
const result = await adapter.describeImage({
|
|
1423
|
+
imageBase64: params.imageBase64,
|
|
1424
|
+
prompt: params.prompt,
|
|
1425
|
+
model: params.model
|
|
1426
|
+
});
|
|
1427
|
+
return result;
|
|
1428
|
+
}
|
|
1429
|
+
};
|
|
1430
|
+
const imageGenTool = {
|
|
1431
|
+
name: `${p}generate_image`,
|
|
1432
|
+
description: "Generate an image from a prompt.",
|
|
1433
|
+
parameters: {
|
|
1434
|
+
type: "object",
|
|
1435
|
+
properties: {
|
|
1436
|
+
prompt: { type: "string", description: "Text prompt for the image." },
|
|
1437
|
+
dimensions: { type: "string", description: "Image size like 1024x1024." },
|
|
1438
|
+
model: { type: "string", description: "Optional model override for image generation." }
|
|
1439
|
+
},
|
|
1440
|
+
required: ["prompt", "dimensions"]
|
|
1441
|
+
},
|
|
1442
|
+
async execute(params, context) {
|
|
1443
|
+
const adapter = requireAdapter(context);
|
|
1444
|
+
const result = await adapter.generateImage({
|
|
1445
|
+
prompt: params.prompt,
|
|
1446
|
+
dimensions: params.dimensions,
|
|
1447
|
+
model: params.model
|
|
1448
|
+
});
|
|
1449
|
+
return result;
|
|
1450
|
+
}
|
|
1451
|
+
};
|
|
1452
|
+
return [transcribeTool, synthesizeTool, visionTool, imageGenTool];
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
// src/agent/execution/StepCounter.ts
|
|
1456
|
+
var StepCounter = class {
|
|
1457
|
+
constructor(maxSteps = 20) {
|
|
1458
|
+
this.maxSteps = maxSteps;
|
|
1459
|
+
}
|
|
1460
|
+
toolCallCount = 0;
|
|
1461
|
+
increment(count = 1) {
|
|
1462
|
+
this.toolCallCount += count;
|
|
1463
|
+
}
|
|
1464
|
+
get count() {
|
|
1465
|
+
return this.toolCallCount;
|
|
1466
|
+
}
|
|
1467
|
+
isMaxReached() {
|
|
1468
|
+
return this.toolCallCount >= this.maxSteps;
|
|
1469
|
+
}
|
|
1470
|
+
getForcedConclusionPrompt() {
|
|
1471
|
+
return `You have used ${this.toolCallCount}/${this.maxSteps} steps. Provide your final response now.
|
|
1472
|
+
Do not call any more tools. Use the required structure below.`;
|
|
1473
|
+
}
|
|
1474
|
+
};
|
|
1475
|
+
|
|
1476
|
+
// src/agent/execution/FinalResponseFormatter.ts
|
|
1477
|
+
var FinalResponseFormatter = class {
|
|
1478
|
+
static getRequiredStructure() {
|
|
1479
|
+
return `## Goal Status: [ACHIEVED | PARTIALLY_ACHIEVED | FAILED]
|
|
1480
|
+
|
|
1481
|
+
### What was accomplished
|
|
1482
|
+
[Summary of completed work]
|
|
1483
|
+
|
|
1484
|
+
### Remaining tasks
|
|
1485
|
+
[Bulleted list, or "None"]
|
|
1486
|
+
|
|
1487
|
+
### Failed steps
|
|
1488
|
+
[Tool/step name + error context, or "None"]
|
|
1489
|
+
|
|
1490
|
+
### Result
|
|
1491
|
+
[The actual answer or deliverable]`;
|
|
1492
|
+
}
|
|
1493
|
+
static validateStructure(response) {
|
|
1494
|
+
if (!response.includes("## Goal Status:")) return false;
|
|
1495
|
+
if (!response.includes("### What was accomplished")) return false;
|
|
1496
|
+
if (!response.includes("### Remaining tasks")) return false;
|
|
1497
|
+
if (!response.includes("### Failed steps")) return false;
|
|
1498
|
+
if (!response.includes("### Result")) return false;
|
|
1499
|
+
return true;
|
|
1500
|
+
}
|
|
1501
|
+
};
|
|
1502
|
+
|
|
1503
|
+
// src/agent/execution/ToolResponseProcessor.ts
|
|
1504
|
+
var ToolResponseProcessor = class {
|
|
1505
|
+
evaluate(response, tool, context) {
|
|
1506
|
+
const length = response.length;
|
|
1507
|
+
const estimatedTokens = length / 4;
|
|
1508
|
+
let sizeClass = "small";
|
|
1509
|
+
if (estimatedTokens > 2e3) sizeClass = "oversized";
|
|
1510
|
+
else if (estimatedTokens > 800) sizeClass = "large";
|
|
1511
|
+
else if (estimatedTokens > 200) sizeClass = "medium";
|
|
1512
|
+
return {
|
|
1513
|
+
relevanceScore: 1,
|
|
1514
|
+
sizeClass,
|
|
1515
|
+
shouldCompress: sizeClass === "large" || sizeClass === "oversized",
|
|
1516
|
+
suggestedMaxTokens: 500,
|
|
1517
|
+
answered: true,
|
|
1518
|
+
answeredPartially: false,
|
|
1519
|
+
errorDetected: response.toLowerCase().includes("error"),
|
|
1520
|
+
suggestedAction: "continue"
|
|
1069
1521
|
};
|
|
1070
1522
|
}
|
|
1071
|
-
|
|
1072
|
-
|
|
1523
|
+
compress(response, evaluation) {
|
|
1524
|
+
if (!evaluation.shouldCompress || evaluation.errorDetected) {
|
|
1525
|
+
return response;
|
|
1526
|
+
}
|
|
1527
|
+
if (response.length > 1e3) {
|
|
1528
|
+
return response.substring(0, 1e3) + "\n...[COMPRESSED TO SAVE TOKENS]...";
|
|
1529
|
+
}
|
|
1530
|
+
return response;
|
|
1073
1531
|
}
|
|
1074
|
-
|
|
1075
|
-
|
|
1532
|
+
};
|
|
1533
|
+
|
|
1534
|
+
// src/agent/execution/GoalInjector.ts
|
|
1535
|
+
var GoalInjector = class {
|
|
1536
|
+
constructor(goal) {
|
|
1537
|
+
this.goal = goal;
|
|
1076
1538
|
}
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
}
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1539
|
+
injectInto(prompt) {
|
|
1540
|
+
const formatting = `[CURRENT GOAL]
|
|
1541
|
+
${this.goal.statement}
|
|
1542
|
+
|
|
1543
|
+
Success criteria:
|
|
1544
|
+
${this.goal.successCriteria.map((c) => `- ${c}`).join("\n")}
|
|
1545
|
+
[/CURRENT GOAL]`;
|
|
1546
|
+
if (this.goal.injectionPosition === "system_prompt") {
|
|
1547
|
+
return `${prompt}
|
|
1548
|
+
|
|
1549
|
+
${formatting}`;
|
|
1550
|
+
}
|
|
1551
|
+
return prompt;
|
|
1552
|
+
}
|
|
1553
|
+
getGoal() {
|
|
1554
|
+
return { ...this.goal };
|
|
1555
|
+
}
|
|
1556
|
+
};
|
|
1557
|
+
var MCPClient = class {
|
|
1558
|
+
config;
|
|
1559
|
+
logger;
|
|
1560
|
+
_serverName;
|
|
1561
|
+
timeoutMs;
|
|
1562
|
+
_tools = [];
|
|
1563
|
+
_connected = false;
|
|
1564
|
+
// stdio-specific state
|
|
1565
|
+
process = null;
|
|
1566
|
+
pendingCallbacks = /* @__PURE__ */ new Map();
|
|
1567
|
+
requestId = 1;
|
|
1568
|
+
stdioBuffer = "";
|
|
1569
|
+
constructor(name, config, logger) {
|
|
1570
|
+
this._serverName = name;
|
|
1571
|
+
this.config = config;
|
|
1572
|
+
this.logger = logger;
|
|
1573
|
+
this.timeoutMs = config.timeoutMs ?? 3e4;
|
|
1574
|
+
}
|
|
1575
|
+
get serverName() {
|
|
1576
|
+
return this._serverName;
|
|
1577
|
+
}
|
|
1578
|
+
get tools() {
|
|
1579
|
+
return this._tools;
|
|
1580
|
+
}
|
|
1581
|
+
get isConnected() {
|
|
1582
|
+
return this._connected;
|
|
1583
|
+
}
|
|
1584
|
+
// ---------------------------------------------------------------------------
|
|
1585
|
+
// Public API
|
|
1586
|
+
// ---------------------------------------------------------------------------
|
|
1587
|
+
/**
|
|
1588
|
+
* Connects to the MCP server: sends `initialize` + `notifications/initialized`,
|
|
1589
|
+
* then fetches the tool list via `tools/list`.
|
|
1590
|
+
*/
|
|
1591
|
+
async connect() {
|
|
1592
|
+
this.logger.debug(`[MCP:${this._serverName}] Connecting via ${this.config.transport}...`);
|
|
1593
|
+
if (this.config.transport === "stdio") {
|
|
1594
|
+
await this._connectStdio();
|
|
1595
|
+
} else {
|
|
1596
|
+
if (!this.config.url) {
|
|
1597
|
+
throw new LemuraMCPConnectionError(
|
|
1598
|
+
`[MCP:${this._serverName}] 'url' is required for ${this.config.transport} transport`,
|
|
1599
|
+
`No URL provided for MCP server '${this._serverName}'`,
|
|
1600
|
+
[`Set 'url' in the MCPServerConfig for server '${this._serverName}'`]
|
|
1601
|
+
);
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
await this._initialize();
|
|
1605
|
+
await this._discoverTools();
|
|
1606
|
+
this._connected = true;
|
|
1607
|
+
this.logger.info(`[MCP:${this._serverName}] Connected. ${this._tools.length} tool(s) available.`);
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* Calls a tool on this MCP server.
|
|
1611
|
+
*
|
|
1612
|
+
* @param toolName - The tool name as declared by the server
|
|
1613
|
+
* @param args - Tool arguments (must match the tool's inputSchema)
|
|
1614
|
+
* @returns The serialised result from the server
|
|
1615
|
+
*/
|
|
1616
|
+
async callTool(toolName, args) {
|
|
1617
|
+
this.logger.debug(`[MCP:${this._serverName}] Calling tool '${toolName}'`, { args });
|
|
1618
|
+
const request = {
|
|
1619
|
+
jsonrpc: "2.0",
|
|
1620
|
+
id: this._nextId(),
|
|
1621
|
+
method: "tools/call",
|
|
1622
|
+
params: { name: toolName, arguments: args }
|
|
1623
|
+
};
|
|
1624
|
+
const response = await this._rpc(request);
|
|
1625
|
+
this._assertNoError(response, `tool '${toolName}'`);
|
|
1626
|
+
const result = response.result;
|
|
1627
|
+
if (result && Array.isArray(result["content"])) {
|
|
1628
|
+
const texts = result["content"].filter((b) => b.type === "text" && b.text).map((b) => b.text);
|
|
1629
|
+
return texts.length === 1 ? texts[0] : texts.length > 1 ? texts.join("\n") : result;
|
|
1630
|
+
}
|
|
1631
|
+
return result;
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Disconnects from the MCP server.
|
|
1635
|
+
* For stdio: sends the `exit` notification then terminates the process.
|
|
1636
|
+
*/
|
|
1637
|
+
async disconnect() {
|
|
1638
|
+
if (!this._connected) return;
|
|
1639
|
+
this.logger.debug(`[MCP:${this._serverName}] Disconnecting...`);
|
|
1640
|
+
this._connected = false;
|
|
1641
|
+
if (this.process) {
|
|
1642
|
+
try {
|
|
1643
|
+
const exitNotif = JSON.stringify({
|
|
1644
|
+
jsonrpc: "2.0",
|
|
1645
|
+
method: "notifications/exit"
|
|
1646
|
+
}) + "\n";
|
|
1647
|
+
this.process.stdin?.write(exitNotif);
|
|
1648
|
+
} catch {
|
|
1649
|
+
}
|
|
1650
|
+
this.process.kill();
|
|
1651
|
+
this.process = null;
|
|
1652
|
+
}
|
|
1653
|
+
for (const [, cb] of this.pendingCallbacks) {
|
|
1654
|
+
cb.reject(new LemuraMCPConnectionError(
|
|
1655
|
+
`[MCP:${this._serverName}] Connection closed before response received`
|
|
1656
|
+
));
|
|
1657
|
+
}
|
|
1658
|
+
this.pendingCallbacks.clear();
|
|
1659
|
+
}
|
|
1660
|
+
// ---------------------------------------------------------------------------
|
|
1661
|
+
// Private: stdio transport
|
|
1662
|
+
// ---------------------------------------------------------------------------
|
|
1663
|
+
async _connectStdio() {
|
|
1664
|
+
const { command, args = [], env = {} } = this.config;
|
|
1665
|
+
if (!command) {
|
|
1666
|
+
throw new LemuraMCPConnectionError(
|
|
1667
|
+
`[MCP:${this._serverName}] 'command' is required for stdio transport`,
|
|
1668
|
+
`No command provided for stdio MCP server '${this._serverName}'`,
|
|
1669
|
+
[`Set the 'command' field in MCPServerConfig for server '${this._serverName}'`]
|
|
1670
|
+
);
|
|
1671
|
+
}
|
|
1672
|
+
return new Promise((resolve, reject) => {
|
|
1673
|
+
let proc;
|
|
1674
|
+
try {
|
|
1675
|
+
proc = spawn(command, args, {
|
|
1676
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1677
|
+
env: { ...process.env, ...env }
|
|
1678
|
+
});
|
|
1679
|
+
} catch (err) {
|
|
1680
|
+
return reject(new LemuraMCPConnectionError(
|
|
1681
|
+
`[MCP:${this._serverName}] Failed to spawn '${command}': ${err.message}`,
|
|
1682
|
+
`Could not start MCP server process for '${this._serverName}'`,
|
|
1683
|
+
[
|
|
1684
|
+
`Ensure '${command}' is installed and available in PATH`,
|
|
1685
|
+
`Check the 'args' and 'env' configuration for server '${this._serverName}'`
|
|
1686
|
+
]
|
|
1687
|
+
));
|
|
1688
|
+
}
|
|
1689
|
+
this.process = proc;
|
|
1690
|
+
proc.on("error", (err) => {
|
|
1691
|
+
this.logger.error(`[MCP:${this._serverName}] Process error: ${err.message}`);
|
|
1692
|
+
for (const [, cb] of this.pendingCallbacks) {
|
|
1693
|
+
cb.reject(new LemuraMCPConnectionError(
|
|
1694
|
+
`[MCP:${this._serverName}] Process error: ${err.message}`
|
|
1695
|
+
));
|
|
1696
|
+
}
|
|
1697
|
+
this.pendingCallbacks.clear();
|
|
1698
|
+
this._connected = false;
|
|
1699
|
+
});
|
|
1700
|
+
proc.on("exit", (code, signal) => {
|
|
1701
|
+
if (this._connected) {
|
|
1702
|
+
this.logger.warn(`[MCP:${this._serverName}] Process exited unexpectedly (code=${code}, signal=${signal})`);
|
|
1703
|
+
this._connected = false;
|
|
1704
|
+
}
|
|
1705
|
+
});
|
|
1706
|
+
proc.stdout?.on("data", (chunk) => {
|
|
1707
|
+
this.stdioBuffer += chunk.toString("utf8");
|
|
1708
|
+
this._flushStdioBuffer();
|
|
1709
|
+
});
|
|
1710
|
+
proc.stderr?.on("data", (chunk) => {
|
|
1711
|
+
this.logger.debug(`[MCP:${this._serverName}] stderr: ${chunk.toString("utf8").trim()}`);
|
|
1712
|
+
});
|
|
1713
|
+
resolve();
|
|
1714
|
+
});
|
|
1715
|
+
}
|
|
1716
|
+
/** Parses newline-delimited JSON messages from the stdio buffer */
|
|
1717
|
+
_flushStdioBuffer() {
|
|
1718
|
+
const lines = this.stdioBuffer.split("\n");
|
|
1719
|
+
this.stdioBuffer = lines.pop() ?? "";
|
|
1720
|
+
for (const line of lines) {
|
|
1721
|
+
const trimmed = line.trim();
|
|
1722
|
+
if (!trimmed) continue;
|
|
1723
|
+
let msg;
|
|
1724
|
+
try {
|
|
1725
|
+
msg = JSON.parse(trimmed);
|
|
1726
|
+
} catch {
|
|
1727
|
+
this.logger.debug(`[MCP:${this._serverName}] Ignoring non-JSON line: ${trimmed.slice(0, 120)}`);
|
|
1728
|
+
continue;
|
|
1729
|
+
}
|
|
1730
|
+
if (msg.id === void 0 || msg.id === null) continue;
|
|
1731
|
+
const cb = this.pendingCallbacks.get(msg.id);
|
|
1732
|
+
if (cb) {
|
|
1733
|
+
this.pendingCallbacks.delete(msg.id);
|
|
1734
|
+
cb.resolve(msg);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
// ---------------------------------------------------------------------------
|
|
1739
|
+
// Private: JSON-RPC helpers
|
|
1740
|
+
// ---------------------------------------------------------------------------
|
|
1741
|
+
_nextId() {
|
|
1742
|
+
return this.requestId++;
|
|
1743
|
+
}
|
|
1744
|
+
/** Sends a JSON-RPC request and returns the response, honouring the configured timeout. */
|
|
1745
|
+
async _rpc(request) {
|
|
1746
|
+
if (this.config.transport === "stdio") {
|
|
1747
|
+
return this._rpcStdio(request);
|
|
1748
|
+
}
|
|
1749
|
+
return this._rpcHttp(request);
|
|
1750
|
+
}
|
|
1751
|
+
_rpcStdio(request) {
|
|
1752
|
+
return new Promise((resolve, reject) => {
|
|
1753
|
+
if (!this.process?.stdin) {
|
|
1754
|
+
return reject(new LemuraMCPConnectionError(
|
|
1755
|
+
`[MCP:${this._serverName}] stdin is not available`
|
|
1756
|
+
));
|
|
1757
|
+
}
|
|
1758
|
+
const id = request.id;
|
|
1759
|
+
let timedOut = false;
|
|
1760
|
+
const timer = setTimeout(() => {
|
|
1761
|
+
timedOut = true;
|
|
1762
|
+
this.pendingCallbacks.delete(id);
|
|
1763
|
+
reject(new LemuraMCPTimeoutError(
|
|
1764
|
+
`[MCP:${this._serverName}] RPC call '${request.method}' timed out after ${this.timeoutMs}ms`
|
|
1765
|
+
));
|
|
1766
|
+
}, this.timeoutMs);
|
|
1767
|
+
this.pendingCallbacks.set(id, {
|
|
1768
|
+
resolve: (value) => {
|
|
1769
|
+
if (timedOut) return;
|
|
1770
|
+
clearTimeout(timer);
|
|
1771
|
+
resolve(value);
|
|
1772
|
+
},
|
|
1773
|
+
reject: (err) => {
|
|
1774
|
+
if (timedOut) return;
|
|
1775
|
+
clearTimeout(timer);
|
|
1776
|
+
reject(err);
|
|
1777
|
+
}
|
|
1778
|
+
});
|
|
1779
|
+
this.process.stdin.write(JSON.stringify(request) + "\n", "utf8");
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
async _rpcHttp(request) {
|
|
1783
|
+
const url = this.config.url;
|
|
1784
|
+
const controller = new AbortController();
|
|
1785
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
1786
|
+
try {
|
|
1787
|
+
const res = await fetch(url, {
|
|
1788
|
+
method: "POST",
|
|
1789
|
+
headers: { "Content-Type": "application/json", "Accept": "application/json" },
|
|
1790
|
+
body: JSON.stringify(request),
|
|
1791
|
+
signal: controller.signal
|
|
1792
|
+
});
|
|
1793
|
+
if (!res.ok) {
|
|
1794
|
+
throw new LemuraMCPConnectionError(
|
|
1795
|
+
`[MCP:${this._serverName}] HTTP ${res.status} from server: ${res.statusText}`,
|
|
1796
|
+
`MCP server '${this._serverName}' returned a non-2xx response`,
|
|
1797
|
+
[`Verify the server is running at '${url}' and accepting JSON-RPC requests`]
|
|
1798
|
+
);
|
|
1799
|
+
}
|
|
1800
|
+
return await res.json();
|
|
1801
|
+
} catch (err) {
|
|
1802
|
+
if (err.name === "AbortError") {
|
|
1803
|
+
throw new LemuraMCPTimeoutError(
|
|
1804
|
+
`[MCP:${this._serverName}] HTTP RPC '${request.method}' timed out after ${this.timeoutMs}ms`
|
|
1805
|
+
);
|
|
1806
|
+
}
|
|
1807
|
+
throw err;
|
|
1808
|
+
} finally {
|
|
1809
|
+
clearTimeout(timer);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
/** Sends `initialize` then `notifications/initialized` per MCP spec */
|
|
1813
|
+
async _initialize() {
|
|
1814
|
+
const initRequest = {
|
|
1815
|
+
jsonrpc: "2.0",
|
|
1816
|
+
id: this._nextId(),
|
|
1817
|
+
method: "initialize",
|
|
1818
|
+
params: {
|
|
1819
|
+
protocolVersion: "2024-11-05",
|
|
1820
|
+
capabilities: { tools: {} },
|
|
1821
|
+
clientInfo: { name: "lemura", version: "1.2.0" }
|
|
1822
|
+
}
|
|
1823
|
+
};
|
|
1824
|
+
let response;
|
|
1825
|
+
try {
|
|
1826
|
+
response = await this._rpc(initRequest);
|
|
1827
|
+
} catch (err) {
|
|
1828
|
+
const msg = err.message ?? String(err);
|
|
1829
|
+
throw new LemuraMCPConnectionError(
|
|
1830
|
+
`[MCP:${this._serverName}] Initialize failed: ${msg}`,
|
|
1831
|
+
`Failed to initialize MCP server '${this._serverName}'`,
|
|
1832
|
+
[
|
|
1833
|
+
`Ensure the server supports the MCP protocol`,
|
|
1834
|
+
`Verify the server is running and accepting connections`
|
|
1835
|
+
]
|
|
1836
|
+
);
|
|
1837
|
+
}
|
|
1838
|
+
this._assertNoError(response, "initialize");
|
|
1839
|
+
const initializedNotif = JSON.stringify({
|
|
1840
|
+
jsonrpc: "2.0",
|
|
1841
|
+
method: "notifications/initialized"
|
|
1842
|
+
}) + "\n";
|
|
1843
|
+
if (this.config.transport === "stdio" && this.process?.stdin) {
|
|
1844
|
+
this.process.stdin.write(initializedNotif, "utf8");
|
|
1845
|
+
} else if (this.config.transport !== "stdio") {
|
|
1846
|
+
fetch(this.config.url, {
|
|
1847
|
+
method: "POST",
|
|
1848
|
+
headers: { "Content-Type": "application/json" },
|
|
1849
|
+
body: initializedNotif
|
|
1850
|
+
}).catch(() => {
|
|
1851
|
+
});
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
/** Fetches and caches the tool list from the server */
|
|
1855
|
+
async _discoverTools() {
|
|
1856
|
+
const request = {
|
|
1857
|
+
jsonrpc: "2.0",
|
|
1858
|
+
id: this._nextId(),
|
|
1859
|
+
method: "tools/list",
|
|
1860
|
+
params: {}
|
|
1861
|
+
};
|
|
1862
|
+
const response = await this._rpc(request);
|
|
1863
|
+
this._assertNoError(response, "tools/list");
|
|
1864
|
+
const result = response.result;
|
|
1865
|
+
const tools = result?.["tools"] ?? [];
|
|
1866
|
+
this._tools = tools;
|
|
1867
|
+
}
|
|
1868
|
+
_assertNoError(response, context) {
|
|
1869
|
+
if (response.error) {
|
|
1870
|
+
throw new LemuraMCPConnectionError(
|
|
1871
|
+
`[MCP:${this._serverName}] JSON-RPC error in ${context}: [${response.error.code}] ${response.error.message}`,
|
|
1872
|
+
`MCP server '${this._serverName}' returned a JSON-RPC error during ${context}`,
|
|
1873
|
+
["Check the MCP server logs for more details"]
|
|
1874
|
+
);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
};
|
|
1878
|
+
|
|
1879
|
+
// src/mcp/MCPClientRegistry.ts
|
|
1880
|
+
var MCPClientRegistry = class {
|
|
1881
|
+
clients = /* @__PURE__ */ new Map();
|
|
1882
|
+
/** toolName → serverName */
|
|
1883
|
+
toolRouter = /* @__PURE__ */ new Map();
|
|
1884
|
+
logger;
|
|
1885
|
+
constructor(logger) {
|
|
1886
|
+
this.logger = logger;
|
|
1887
|
+
}
|
|
1888
|
+
// ---------------------------------------------------------------------------
|
|
1889
|
+
// Registration
|
|
1890
|
+
// ---------------------------------------------------------------------------
|
|
1891
|
+
/**
|
|
1892
|
+
* Creates an `MCPClient` for the given config, connects to the server, and
|
|
1893
|
+
* registers the server by name.
|
|
1894
|
+
*
|
|
1895
|
+
* @throws {LemuraMCPConnectionError} if connection / initialization fails
|
|
1896
|
+
*/
|
|
1897
|
+
async register(name, config) {
|
|
1898
|
+
if (this.clients.has(name)) {
|
|
1899
|
+
this.logger.warn(`[MCPClientRegistry] Server '${name}' is already registered \u2014 skipping`);
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
const client = new MCPClient(name, config, this.logger);
|
|
1903
|
+
try {
|
|
1904
|
+
await client.connect();
|
|
1905
|
+
} catch (err) {
|
|
1906
|
+
const msg = err.message ?? String(err);
|
|
1907
|
+
throw new LemuraMCPConnectionError(
|
|
1908
|
+
`[MCPClientRegistry] Failed to connect to server '${name}': ${msg}`,
|
|
1909
|
+
`Could not establish connection to MCP server '${name}'`,
|
|
1910
|
+
[
|
|
1911
|
+
`Verify the server is reachable and the transport config is correct`,
|
|
1912
|
+
`Check the 'command', 'args', or 'url' for server '${name}'`
|
|
1913
|
+
]
|
|
1914
|
+
);
|
|
1915
|
+
}
|
|
1916
|
+
this.clients.set(name, client);
|
|
1917
|
+
this.logger.info(`[MCPClientRegistry] Server '${name}' registered (${client.tools.length} tools)`);
|
|
1918
|
+
}
|
|
1919
|
+
// ---------------------------------------------------------------------------
|
|
1920
|
+
// Tool discovery
|
|
1921
|
+
// ---------------------------------------------------------------------------
|
|
1922
|
+
/**
|
|
1923
|
+
* Collects tools from all connected MCP servers and returns them as
|
|
1924
|
+
* `IToolDefinition` adapters ready to be registered in `ToolRegistry`.
|
|
1925
|
+
*
|
|
1926
|
+
* Tool names are **not** namespaced — if two servers expose a tool with the
|
|
1927
|
+
* same name, the last-registered server wins and a warning is emitted.
|
|
1928
|
+
*/
|
|
1929
|
+
async discoverTools() {
|
|
1930
|
+
const bridged = [];
|
|
1931
|
+
for (const [serverName, client] of this.clients) {
|
|
1932
|
+
for (const mcpTool of client.tools) {
|
|
1933
|
+
if (this.toolRouter.has(mcpTool.name)) {
|
|
1934
|
+
this.logger.warn(
|
|
1935
|
+
`[MCPClientRegistry] Tool '${mcpTool.name}' is already provided by '${this.toolRouter.get(mcpTool.name)}'; overriding with server '${serverName}'`
|
|
1936
|
+
);
|
|
1937
|
+
}
|
|
1938
|
+
this.toolRouter.set(mcpTool.name, serverName);
|
|
1939
|
+
bridged.push(this._bridge(mcpTool, serverName));
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
this.logger.debug(`[MCPClientRegistry] Discovered ${bridged.length} total MCP tool(s)`);
|
|
1943
|
+
return bridged;
|
|
1944
|
+
}
|
|
1945
|
+
// ---------------------------------------------------------------------------
|
|
1946
|
+
// Routing
|
|
1947
|
+
// ---------------------------------------------------------------------------
|
|
1948
|
+
/**
|
|
1949
|
+
* Calls a tool on the correct MCP server.
|
|
1950
|
+
*
|
|
1951
|
+
* @param toolName - The tool name as registered via `discoverTools()`
|
|
1952
|
+
* @param args - Parsed arguments object
|
|
1953
|
+
* @throws {LemuraMCPConnectionError} if no server is registered for `toolName`
|
|
1954
|
+
*/
|
|
1955
|
+
async callTool(toolName, args) {
|
|
1956
|
+
const serverName = this.toolRouter.get(toolName);
|
|
1957
|
+
if (!serverName) {
|
|
1958
|
+
throw new LemuraMCPConnectionError(
|
|
1959
|
+
`[MCPClientRegistry] No MCP server found for tool '${toolName}'`,
|
|
1960
|
+
`Tool '${toolName}' was not discovered from any connected MCP server`,
|
|
1961
|
+
["Verify the MCP server that provides this tool is configured and connected"]
|
|
1962
|
+
);
|
|
1963
|
+
}
|
|
1964
|
+
const client = this.clients.get(serverName);
|
|
1965
|
+
return client.callTool(toolName, args);
|
|
1966
|
+
}
|
|
1967
|
+
// ---------------------------------------------------------------------------
|
|
1968
|
+
// Lifecycle
|
|
1969
|
+
// ---------------------------------------------------------------------------
|
|
1970
|
+
/**
|
|
1971
|
+
* Disconnects all registered MCP servers gracefully.
|
|
1972
|
+
* Called automatically by `SessionManager.close()`.
|
|
1973
|
+
*/
|
|
1974
|
+
async disconnectAll() {
|
|
1975
|
+
const names = Array.from(this.clients.keys());
|
|
1976
|
+
this.logger.debug(`[MCPClientRegistry] Disconnecting ${names.length} MCP server(s)...`);
|
|
1977
|
+
await Promise.allSettled(
|
|
1978
|
+
names.map(async (name) => {
|
|
1979
|
+
try {
|
|
1980
|
+
await this.clients.get(name).disconnect();
|
|
1981
|
+
this.logger.debug(`[MCPClientRegistry] '${name}' disconnected`);
|
|
1982
|
+
} catch (err) {
|
|
1983
|
+
this.logger.warn(
|
|
1984
|
+
`[MCPClientRegistry] Error disconnecting '${name}': ${err.message}`
|
|
1985
|
+
);
|
|
1986
|
+
}
|
|
1987
|
+
})
|
|
1988
|
+
);
|
|
1989
|
+
this.clients.clear();
|
|
1990
|
+
this.toolRouter.clear();
|
|
1991
|
+
}
|
|
1992
|
+
/** Returns the names of all currently registered servers */
|
|
1993
|
+
getRegisteredServers() {
|
|
1994
|
+
return Array.from(this.clients.keys());
|
|
1995
|
+
}
|
|
1996
|
+
// ---------------------------------------------------------------------------
|
|
1997
|
+
// Private helpers
|
|
1998
|
+
// ---------------------------------------------------------------------------
|
|
1999
|
+
/**
|
|
2000
|
+
* Bridges an `MCPToolDefinition` from the protocol into an `IToolDefinition`
|
|
2001
|
+
* that the existing `ToolRegistry` can execute.
|
|
2002
|
+
*/
|
|
2003
|
+
_bridge(mcpTool, serverName) {
|
|
2004
|
+
const self = this;
|
|
2005
|
+
return {
|
|
2006
|
+
name: mcpTool.name,
|
|
2007
|
+
description: `[MCP:${serverName}] ${mcpTool.description}`,
|
|
2008
|
+
parameters: mcpTool.inputSchema ?? { type: "object", properties: {} },
|
|
2009
|
+
async execute(params, _context) {
|
|
2010
|
+
return self.callTool(mcpTool.name, params ?? {});
|
|
2011
|
+
}
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
};
|
|
2015
|
+
|
|
2016
|
+
// src/agent/SessionManager.ts
|
|
2017
|
+
var SessionManager = class {
|
|
2018
|
+
contextManager;
|
|
2019
|
+
toolRegistry;
|
|
2020
|
+
skillInjector;
|
|
2021
|
+
context;
|
|
2022
|
+
adapter;
|
|
2023
|
+
config;
|
|
2024
|
+
iterations = 0;
|
|
2025
|
+
logger;
|
|
2026
|
+
media;
|
|
2027
|
+
// Advanced execution state
|
|
2028
|
+
stepCounter;
|
|
2029
|
+
toolResponseProcessor;
|
|
2030
|
+
goalInjector = null;
|
|
2031
|
+
continuationPlanner = null;
|
|
2032
|
+
// MCP
|
|
2033
|
+
mcpRegistry = null;
|
|
2034
|
+
/** Resolves when all MCP servers are connected; used by run() and stream() */
|
|
2035
|
+
mcpReady = null;
|
|
2036
|
+
// Tool execution budget tracking
|
|
2037
|
+
totalToolCallCount = 0;
|
|
2038
|
+
perToolCallCount = /* @__PURE__ */ new Map();
|
|
2039
|
+
totalTokens = 0;
|
|
2040
|
+
constructor(config) {
|
|
2041
|
+
this.config = config;
|
|
2042
|
+
this.adapter = config.adapter;
|
|
2043
|
+
this.logger = config.logger || new DefaultLogger();
|
|
2044
|
+
this.contextManager = new ContextManager();
|
|
2045
|
+
this.toolRegistry = new ToolRegistry(config.tools || [], {
|
|
2046
|
+
defaultTimeoutMs: config.toolRegistryTimeoutMs ?? 3e4
|
|
2047
|
+
});
|
|
2048
|
+
this.skillInjector = new SkillInjector(config.skills || []);
|
|
2049
|
+
this.media = new MediaBridge(this.adapter);
|
|
2050
|
+
this.stepCounter = new StepCounter(config.maxSteps ?? 20);
|
|
2051
|
+
this.toolResponseProcessor = config.toolResponseProcessor ?? new ToolResponseProcessor();
|
|
2052
|
+
for (const strategy of config.compressionStrategies || []) {
|
|
2053
|
+
this.contextManager.registerStrategy(strategy);
|
|
2054
|
+
}
|
|
2055
|
+
if (config.stmRegistry) {
|
|
2056
|
+
this.toolRegistry.register(readChunkTool);
|
|
2057
|
+
this.toolRegistry.register(searchChunkTool);
|
|
2058
|
+
this.toolRegistry.register(listChunksTool);
|
|
2059
|
+
this.toolRegistry.register(updateChunkTool);
|
|
2060
|
+
this.toolRegistry.register(readScratchpadTool);
|
|
2061
|
+
this.toolRegistry.register(writeScratchpadTool);
|
|
2062
|
+
this.toolRegistry.register(removeScratchpadTool);
|
|
2063
|
+
this.toolRegistry.register(summarizeSandwichTool);
|
|
2064
|
+
}
|
|
2065
|
+
if (config.media?.enableTools) {
|
|
2066
|
+
const prefix = config.media.toolPrefix || "media_";
|
|
2067
|
+
for (const tool of createMediaTools(prefix)) {
|
|
2068
|
+
this.toolRegistry.register(tool);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
this.context = {
|
|
2072
|
+
systemPrompt: config.systemPrompt || "",
|
|
2073
|
+
scratchpad: "",
|
|
2074
|
+
turns: [],
|
|
2075
|
+
tokenCount: 0,
|
|
2076
|
+
maxTokens: config.maxTokens,
|
|
2077
|
+
metadata: {}
|
|
2078
|
+
};
|
|
2079
|
+
this.goalInjector = null;
|
|
2080
|
+
if (config.mcpServers && config.mcpServers.length > 0) {
|
|
2081
|
+
this.mcpRegistry = new MCPClientRegistry(this.logger);
|
|
2082
|
+
this.mcpReady = this._initMCP(config.mcpServers);
|
|
2083
|
+
}
|
|
2084
|
+
this.emitTrace("system", "session_init", {
|
|
2085
|
+
config: {
|
|
2086
|
+
model: this.config.model,
|
|
2087
|
+
maxIterations: this.config.maxIterations,
|
|
2088
|
+
maxSteps: this.config.maxSteps,
|
|
2089
|
+
parallelToolCalls: this.config.parallelToolCalls,
|
|
2090
|
+
enableGoalPlanning: this.config.enableGoalPlanning,
|
|
2091
|
+
enableContinuationPlanning: this.config.enableContinuationPlanning
|
|
2092
|
+
}
|
|
2093
|
+
});
|
|
2094
|
+
}
|
|
2095
|
+
emitTrace(type, name, metadata, input, output, status = "done") {
|
|
2096
|
+
if (this.config.onTrace) {
|
|
2097
|
+
this.config.onTrace({
|
|
2098
|
+
type,
|
|
2099
|
+
name,
|
|
2100
|
+
metadata: metadata || {},
|
|
2101
|
+
input,
|
|
2102
|
+
output,
|
|
2103
|
+
status,
|
|
2104
|
+
startedAt: Date.now()
|
|
2105
|
+
});
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
/**
|
|
2109
|
+
* Returns a shallow copy of the current context window.
|
|
2110
|
+
*/
|
|
2111
|
+
getContext() {
|
|
2112
|
+
return { ...this.context };
|
|
2113
|
+
}
|
|
2114
|
+
/**
|
|
2115
|
+
* Returns the current conversation history.
|
|
2116
|
+
*/
|
|
2117
|
+
getHistory() {
|
|
2118
|
+
return [...this.context.turns];
|
|
2119
|
+
}
|
|
2120
|
+
/**
|
|
2121
|
+
* Returns the `MediaBridge` for direct ASR / TTS / Vision / Image-gen calls.
|
|
2122
|
+
*/
|
|
2123
|
+
getMedia() {
|
|
2124
|
+
return this.media;
|
|
2125
|
+
}
|
|
2126
|
+
// -----------------------------------------------------------------------
|
|
2127
|
+
// MCP initialisation
|
|
2128
|
+
// -----------------------------------------------------------------------
|
|
2129
|
+
/**
|
|
2130
|
+
* Connects all configured MCP servers and registers their bridged tools.
|
|
2131
|
+
* Called from the constructor as a fire-and-start async task; `run()` and
|
|
2132
|
+
* `stream()` await `this.mcpReady` before executing.
|
|
2133
|
+
*/
|
|
2134
|
+
async _initMCP(mcpServers) {
|
|
2135
|
+
if (!this.mcpRegistry) return;
|
|
2136
|
+
this.emitTrace("system", "mcp_init_start", { serverCount: mcpServers.length });
|
|
2137
|
+
this.logger.info(`[MCP] Connecting to ${mcpServers.length} server(s)...`);
|
|
2138
|
+
for (const serverConfig of mcpServers) {
|
|
2139
|
+
try {
|
|
2140
|
+
await this.mcpRegistry.register(serverConfig.name, serverConfig);
|
|
2141
|
+
this.emitTrace("system", "mcp_server_connected", { server: serverConfig.name });
|
|
2142
|
+
} catch (err) {
|
|
2143
|
+
const msg = err.message ?? String(err);
|
|
2144
|
+
this.logger.error(`[MCP] Failed to connect '${serverConfig.name}': ${msg}`);
|
|
2145
|
+
this.emitTrace("error", "mcp_server_failed", { server: serverConfig.name, error: msg });
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
const bridgedTools = await this.mcpRegistry.discoverTools();
|
|
2149
|
+
for (const tool of bridgedTools) {
|
|
2150
|
+
try {
|
|
2151
|
+
this.toolRegistry.register(tool);
|
|
2152
|
+
} catch {
|
|
2153
|
+
this.logger.warn(`[MCP] Tool '${tool.name}' conflicts with an existing tool \u2014 skipping`);
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
this.logger.info(`[MCP] ${bridgedTools.length} MCP tool(s) registered`);
|
|
2157
|
+
this.emitTrace("system", "mcp_init_done", {
|
|
2158
|
+
servers: this.mcpRegistry.getRegisteredServers(),
|
|
2159
|
+
toolCount: bridgedTools.length
|
|
2160
|
+
});
|
|
2161
|
+
}
|
|
2162
|
+
// -----------------------------------------------------------------------
|
|
2163
|
+
// Internal helpers
|
|
2164
|
+
// -----------------------------------------------------------------------
|
|
2165
|
+
/** Builds the system prompt, injecting skills and goal if configured. */
|
|
2166
|
+
buildSystemPrompt(userMessage) {
|
|
2167
|
+
let prompt = this.context.systemPrompt || "";
|
|
2168
|
+
if (this.config.enableGoalPlanning && userMessage) {
|
|
2169
|
+
if (!this.goalInjector) {
|
|
2170
|
+
this.goalInjector = new GoalInjector({
|
|
2171
|
+
id: "main",
|
|
2172
|
+
statement: typeof userMessage === "string" ? userMessage : "[multimodal]",
|
|
2173
|
+
decomposition: [],
|
|
2174
|
+
successCriteria: ["The user request is fully answered"],
|
|
2175
|
+
injectionFrequency: this.config.goalInjectionFrequency ?? "always",
|
|
2176
|
+
injectionPosition: this.config.goalInjectionPosition ?? "system_prompt"
|
|
2177
|
+
});
|
|
2178
|
+
this.logger.debug("Goal injector initialised");
|
|
2179
|
+
const goal = this.goalInjector.getGoal();
|
|
2180
|
+
this.emitTrace("planning", "goal_init", {
|
|
2181
|
+
statement: goal.statement,
|
|
2182
|
+
criteria: goal.successCriteria
|
|
2183
|
+
});
|
|
2184
|
+
}
|
|
2185
|
+
if (this.config.goalInjectionPosition !== "pre_turn") {
|
|
2186
|
+
prompt = this.goalInjector.injectInto(prompt);
|
|
2187
|
+
this.emitTrace("planning", "goal_injected", { position: this.config.goalInjectionPosition || "system_prompt" });
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
const injectedSkills = this.skillInjector.buildInjectionBlock("system_prompt");
|
|
2191
|
+
if (injectedSkills) {
|
|
2192
|
+
prompt += "\n\n" + injectedSkills;
|
|
2193
|
+
}
|
|
2194
|
+
return prompt.trim();
|
|
2195
|
+
}
|
|
2196
|
+
/** Builds the messages array for the provider from the current context */
|
|
2197
|
+
buildMessages(systemPrompt) {
|
|
2198
|
+
const messages = this.context.turns.map((t) => ({
|
|
2199
|
+
role: t.role,
|
|
2200
|
+
content: t.content,
|
|
2201
|
+
...t.role === "tool" && t.toolResults?.[0] ? { name: t.toolResults[0].toolCallId } : {},
|
|
2202
|
+
...t.role === "assistant" && t.toolCalls ? { toolCalls: t.toolCalls } : {}
|
|
2203
|
+
}));
|
|
2204
|
+
if (systemPrompt) {
|
|
2205
|
+
messages.unshift({ role: "system", content: systemPrompt });
|
|
2206
|
+
}
|
|
2207
|
+
if (this.goalInjector && this.config.goalInjectionPosition === "pre_turn") {
|
|
2208
|
+
const goalBlock = this.goalInjector.injectInto("");
|
|
2209
|
+
messages.push({ role: "system", content: goalBlock });
|
|
2210
|
+
this.emitTrace("planning", "goal_injected", { position: "pre_turn" });
|
|
2211
|
+
}
|
|
2212
|
+
return messages;
|
|
2213
|
+
}
|
|
2214
|
+
/** Checks the tool execution budget and throws descriptively if exceeded */
|
|
2215
|
+
checkExecutionBudget(toolName) {
|
|
2216
|
+
const budget = this.config.toolExecutionBudget;
|
|
2217
|
+
if (!budget) return;
|
|
2218
|
+
if (budget.maxCallsPerSession !== void 0 && this.totalToolCallCount >= budget.maxCallsPerSession) {
|
|
2219
|
+
const err = new LemuraMaxIterationsError(
|
|
2220
|
+
`Tool execution budget exceeded: session limit of ${budget.maxCallsPerSession} tool calls reached`
|
|
2221
|
+
);
|
|
2222
|
+
this.logger.warn(err.message);
|
|
2223
|
+
throw err;
|
|
2224
|
+
}
|
|
2225
|
+
if (budget.maxCallsPerTool?.[toolName] !== void 0) {
|
|
2226
|
+
const current = this.perToolCallCount.get(toolName) ?? 0;
|
|
2227
|
+
if (current >= (budget.maxCallsPerTool[toolName] ?? Infinity)) {
|
|
2228
|
+
const err = new LemuraMaxIterationsError(
|
|
2229
|
+
`Tool execution budget exceeded: '${toolName}' has reached its per-tool limit of ${budget.maxCallsPerTool[toolName]}`
|
|
2230
|
+
);
|
|
2231
|
+
this.logger.warn(err.message);
|
|
2232
|
+
this.emitTrace("budget", "tool_limit_exceeded", { toolName, limit: budget.maxCallsPerTool[toolName], totalTokens: this.totalTokens });
|
|
2233
|
+
throw err;
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
this.emitTrace("budget", "check_passed", {
|
|
2237
|
+
toolName,
|
|
2238
|
+
totalCalls: this.totalToolCallCount,
|
|
2239
|
+
totalTokens: this.totalTokens,
|
|
2240
|
+
tokenBudgetRemaining: this.config.maxTokens - this.totalTokens
|
|
2241
|
+
});
|
|
2242
|
+
}
|
|
2243
|
+
/** Records a tool call in budget counters */
|
|
2244
|
+
recordToolCall(toolName) {
|
|
2245
|
+
this.totalToolCallCount++;
|
|
2246
|
+
this.perToolCallCount.set(toolName, (this.perToolCallCount.get(toolName) ?? 0) + 1);
|
|
2247
|
+
}
|
|
2248
|
+
/**
|
|
2249
|
+
* Processes a firewall decision for a tool call.
|
|
2250
|
+
* Returns true to proceed, false to block.
|
|
2251
|
+
*/
|
|
2252
|
+
async passesFirewall(toolName, argsJson, toolCallId, toolResults) {
|
|
2253
|
+
const firewall = evaluateToolFirewall(
|
|
2254
|
+
this.config.toolFirewall,
|
|
2255
|
+
toolName,
|
|
2256
|
+
argsJson,
|
|
2257
|
+
this.logger
|
|
2258
|
+
);
|
|
2259
|
+
if (firewall.decision === "deny") {
|
|
2260
|
+
this.logger.warn(`Tool blocked by firewall: ${toolName}`, { reason: firewall.reason });
|
|
2261
|
+
toolResults.push({ toolCallId, content: `Blocked by tool firewall: ${firewall.reason}` });
|
|
2262
|
+
return false;
|
|
2263
|
+
}
|
|
2264
|
+
if (firewall.decision === "ask") {
|
|
2265
|
+
if (this.config.toolFirewall?.onAsk) {
|
|
2266
|
+
const userDecision = await this.config.toolFirewall.onAsk(toolName, argsJson);
|
|
2267
|
+
if (userDecision === "deny") {
|
|
2268
|
+
this.logger.warn(`Tool blocked by firewall (ask \u2192 deny): ${toolName}`, { reason: firewall.reason });
|
|
2269
|
+
toolResults.push({ toolCallId, content: `Blocked by tool firewall: ${firewall.reason}` });
|
|
2270
|
+
return false;
|
|
2271
|
+
}
|
|
2272
|
+
} else {
|
|
2273
|
+
this.logger.warn(`Tool blocked by firewall (ask without handler): ${toolName}`, { reason: firewall.reason });
|
|
2274
|
+
toolResults.push({ toolCallId, content: `Blocked by tool firewall: ${firewall.reason}` });
|
|
2275
|
+
return false;
|
|
2276
|
+
}
|
|
2277
|
+
}
|
|
2278
|
+
return true;
|
|
2279
|
+
}
|
|
2280
|
+
/**
|
|
2281
|
+
* Executes a single parsed tool call and returns the serialised result string.
|
|
2282
|
+
*/
|
|
2283
|
+
async executeSingleToolCall(tc) {
|
|
2284
|
+
this.checkExecutionBudget(tc.name);
|
|
2285
|
+
const args = JSON.parse(tc.arguments);
|
|
2286
|
+
const executeContext = {
|
|
2287
|
+
sessionId: "default",
|
|
2288
|
+
turnIndex: this.context.turns.length,
|
|
2289
|
+
logger: this.logger,
|
|
2290
|
+
adapter: this.adapter,
|
|
2291
|
+
stmRegistry: this.config.stmRegistry,
|
|
2292
|
+
scratchpad: this.context.scratchpad
|
|
2293
|
+
};
|
|
2294
|
+
if (this.config.ragAdapter) {
|
|
2295
|
+
executeContext["ragAdapter"] = this.config.ragAdapter;
|
|
2296
|
+
}
|
|
2297
|
+
this.logger.debug(`Executing tool: ${tc.name}`, { args: tc.arguments });
|
|
2298
|
+
this.emitTrace("tool_call", tc.name, { id: tc.id }, tc.arguments, null, "running");
|
|
2299
|
+
const result = await this.toolRegistry.execute(tc.name, args, executeContext);
|
|
2300
|
+
this.recordToolCall(tc.name);
|
|
2301
|
+
this.logger.debug(`Tool ${tc.name} returned successfully`);
|
|
2302
|
+
let finalResult = result;
|
|
2303
|
+
if (typeof result === "object" && result !== null) {
|
|
2304
|
+
const resObj = result;
|
|
2305
|
+
if (resObj["status"] === "success" && resObj["newScratchpad"] !== void 0) {
|
|
2306
|
+
this.context.scratchpad = resObj["newScratchpad"];
|
|
2307
|
+
finalResult = resObj["note"] || "Scratchpad updated";
|
|
2308
|
+
this.emitTrace("planning", "scratchpad_update", { note: resObj["note"] });
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
let content = JSON.stringify(finalResult);
|
|
2312
|
+
const tokenCount = this.adapter.estimateTokens(content);
|
|
2313
|
+
if (this.config.maxTokensPerTool && tokenCount > this.config.maxTokensPerTool) {
|
|
2314
|
+
content = content.slice(0, this.config.maxTokensPerTool * 4) + "... [TRUNCATED DUE TO TOOL TOKEN LIMIT]";
|
|
2315
|
+
}
|
|
2316
|
+
const toolDef = this.toolRegistry.get(tc.name) || {
|
|
2317
|
+
name: tc.name,
|
|
2318
|
+
description: "",
|
|
2319
|
+
parameters: {},
|
|
2320
|
+
execute: async () => void 0
|
|
2321
|
+
};
|
|
2322
|
+
const evaluation = this.toolResponseProcessor.evaluate(content, toolDef, this.context);
|
|
2323
|
+
if (evaluation.shouldCompress && !evaluation.errorDetected) {
|
|
2324
|
+
content = this.toolResponseProcessor.compress(content, evaluation);
|
|
2325
|
+
this.emitTrace("compression", tc.name, { originalSize: evaluation.sizeClass }, null, content);
|
|
2326
|
+
}
|
|
2327
|
+
if (this.config.enableContinuationPlanning && evaluation.suggestedAction === "continue") {
|
|
2328
|
+
this.emitTrace("planning", "continuation_detected", { toolName: tc.name, action: evaluation.suggestedAction });
|
|
2329
|
+
}
|
|
2330
|
+
this.emitTrace("tool_result", tc.name, { id: tc.id }, tc.arguments, content, "done");
|
|
2331
|
+
return content;
|
|
2332
|
+
}
|
|
2333
|
+
// -----------------------------------------------------------------------
|
|
2334
|
+
// Public API
|
|
2335
|
+
// -----------------------------------------------------------------------
|
|
2336
|
+
/**
|
|
2337
|
+
* Runs the full ReAct loop for a user message and returns the final assistant response.
|
|
2338
|
+
*
|
|
2339
|
+
* @param userMessage - The user's message (string or multimodal content blocks)
|
|
2340
|
+
* @returns The assistant's final response string
|
|
2341
|
+
* @throws {LemuraMaxIterationsError} When the loop exceeds `maxIterations`
|
|
2342
|
+
*/
|
|
2343
|
+
async run(userMessage) {
|
|
2344
|
+
if (this.mcpReady) await this.mcpReady;
|
|
2345
|
+
const userMessageStr = Array.isArray(userMessage) ? "[Multimodal Content]" : userMessage;
|
|
2346
|
+
this.logger.info(`Starting new session run`, {
|
|
2347
|
+
model: this.config.model,
|
|
2348
|
+
message: userMessageStr
|
|
2349
|
+
});
|
|
2350
|
+
const systemPrompt = this.buildSystemPrompt(userMessageStr);
|
|
2351
|
+
this.context.turns.push({
|
|
2352
|
+
role: "user",
|
|
2353
|
+
content: userMessage,
|
|
2354
|
+
tokenCount: Array.isArray(userMessage) ? userMessage.length * 50 : this.adapter.estimateTokens(userMessage),
|
|
2355
|
+
turnIndex: this.context.turns.length,
|
|
2356
|
+
compressed: false
|
|
2357
|
+
});
|
|
2358
|
+
const maxIts = this.config.maxIterations || 10;
|
|
2359
|
+
this.iterations = 0;
|
|
2360
|
+
this.stepCounter = new StepCounter(this.config.maxSteps ?? 20);
|
|
2361
|
+
while (this.iterations < maxIts) {
|
|
2362
|
+
this.iterations++;
|
|
2363
|
+
this.logger.debug(`ReAct Iteration ${this.iterations}/${maxIts}`);
|
|
2364
|
+
this.context = await this.contextManager.prepare(this.context);
|
|
2365
|
+
let messages = this.buildMessages(systemPrompt);
|
|
2366
|
+
if (this.stepCounter.isMaxReached()) {
|
|
2367
|
+
this.logger.warn(`maxSteps (${this.config.maxSteps ?? 20}) reached \u2014 forcing final response`);
|
|
2368
|
+
messages.push({
|
|
2369
|
+
role: "system",
|
|
2370
|
+
content: this.stepCounter.getForcedConclusionPrompt() + "\n\n" + FinalResponseFormatter.getRequiredStructure()
|
|
2371
|
+
});
|
|
2372
|
+
this.emitTrace("planning", "max_steps_reached", { maxSteps: this.config.maxSteps, currentSteps: this.stepCounter.count });
|
|
1108
2373
|
}
|
|
1109
2374
|
this.logger.debug(`Calling provider adapter (${this.adapter.name})...`);
|
|
2375
|
+
this.emitTrace("thinking", "llm_call", {
|
|
2376
|
+
model: this.config.model,
|
|
2377
|
+
iteration: this.iterations,
|
|
2378
|
+
totalTokens: this.totalTokens
|
|
2379
|
+
}, null, null, "running");
|
|
1110
2380
|
let response;
|
|
1111
2381
|
try {
|
|
1112
2382
|
response = await this.adapter.complete({
|
|
1113
2383
|
model: this.config.model,
|
|
1114
2384
|
messages,
|
|
1115
|
-
tools: this.toolRegistry.getAll(),
|
|
2385
|
+
tools: this.stepCounter.isMaxReached() ? [] : this.toolRegistry.getAll(),
|
|
1116
2386
|
maxTokens: 1e3
|
|
1117
|
-
// default gen tokens
|
|
1118
2387
|
});
|
|
1119
2388
|
} catch (err) {
|
|
1120
|
-
const
|
|
1121
|
-
|
|
2389
|
+
const e = err;
|
|
2390
|
+
const metadata = e.problem ? { problem: e.problem, hints: e.hints ?? [] } : {};
|
|
2391
|
+
this.logger.fatal(`Provider call failed: ${e.message ?? String(err)}`, metadata);
|
|
2392
|
+
this.emitTrace("error", "llm_call_failed", { error: e.message ?? String(err) });
|
|
1122
2393
|
throw err;
|
|
1123
2394
|
}
|
|
2395
|
+
if (response.usage) {
|
|
2396
|
+
this.totalTokens += response.usage.totalTokens;
|
|
2397
|
+
}
|
|
2398
|
+
this.emitTrace("thinking", "llm_call", {
|
|
2399
|
+
model: this.config.model,
|
|
2400
|
+
usage: response.usage,
|
|
2401
|
+
totalTokens: this.totalTokens
|
|
2402
|
+
}, null, response.content, "done");
|
|
1124
2403
|
if (response.finishReason === "tool_call" && response.toolCalls) {
|
|
1125
2404
|
this.logger.info(`Assistant requested ${response.toolCalls.length} tool calls`, {
|
|
1126
2405
|
tools: response.toolCalls.map((tc) => tc.name)
|
|
1127
2406
|
});
|
|
2407
|
+
this.stepCounter.increment(response.toolCalls.length);
|
|
1128
2408
|
const toolResults = [];
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
if (this.config.ragAdapter) {
|
|
1140
|
-
executeContext.ragAdapter = this.config.ragAdapter;
|
|
1141
|
-
}
|
|
1142
|
-
this.logger.debug(`Executing tool: ${tc.name}`, { args: tc.arguments });
|
|
1143
|
-
const result = await this.toolRegistry.execute(tc.name, args, executeContext);
|
|
1144
|
-
this.logger.debug(`Tool ${tc.name} returned successfully`);
|
|
1145
|
-
let finalResult = result;
|
|
1146
|
-
if (typeof result === "object" && result !== null) {
|
|
1147
|
-
const resObj = result;
|
|
1148
|
-
if (resObj.status === "success" && resObj.newScratchpad !== void 0) {
|
|
1149
|
-
this.context.scratchpad = resObj.newScratchpad;
|
|
1150
|
-
finalResult = resObj.note || "Scratchpad updated";
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
let content = JSON.stringify(finalResult);
|
|
1154
|
-
const tokenCount = this.adapter.estimateTokens(content);
|
|
1155
|
-
if (this.config.maxTokensPerTool && tokenCount > this.config.maxTokensPerTool) {
|
|
1156
|
-
content = content.slice(0, this.config.maxTokensPerTool * 4) + "... [TRUNCATED DUE TO TOOL TOKEN LIMIT]";
|
|
2409
|
+
if (this.config.parallelToolCalls) {
|
|
2410
|
+
const budget = this.config.toolExecutionBudget;
|
|
2411
|
+
const maxConcurrent = budget?.maxConcurrentCalls ?? response.toolCalls.length;
|
|
2412
|
+
for (let i = 0; i < response.toolCalls.length; i += maxConcurrent) {
|
|
2413
|
+
const batch = response.toolCalls.slice(i, i + maxConcurrent);
|
|
2414
|
+
const allowed = [];
|
|
2415
|
+
for (const tc of batch) {
|
|
2416
|
+
const ok = await this.passesFirewall(tc.name, tc.arguments, tc.id, toolResults);
|
|
2417
|
+
if (ok) allowed.push(tc);
|
|
2418
|
+
else this.emitTrace("budget", "firewall_blocked", { toolName: tc.name });
|
|
1157
2419
|
}
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
problem: e.problem || `Tool ${tc.name} failed to execute properly.`,
|
|
1162
|
-
hints: e.hints || ["Check the tool parameters and ensure the required services are running."]
|
|
2420
|
+
this.emitTrace("planning", "parallel_execution", {
|
|
2421
|
+
batchSize: allowed.length,
|
|
2422
|
+
totalInResponse: response.toolCalls.length
|
|
1163
2423
|
});
|
|
1164
|
-
|
|
2424
|
+
const batchResults = await Promise.all(
|
|
2425
|
+
allowed.map(async (tc) => {
|
|
2426
|
+
try {
|
|
2427
|
+
const content = await this.executeSingleToolCall(tc);
|
|
2428
|
+
return { toolCallId: tc.id, content };
|
|
2429
|
+
} catch (e) {
|
|
2430
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2431
|
+
const isTimeout = e instanceof LemuraToolTimeoutError;
|
|
2432
|
+
this.logger.error(`Tool ${tc.name} ${isTimeout ? "timed out" : "failed"}: ${msg}`);
|
|
2433
|
+
return { toolCallId: tc.id, content: `Error: ${msg}` };
|
|
2434
|
+
}
|
|
2435
|
+
})
|
|
2436
|
+
);
|
|
2437
|
+
toolResults.push(...batchResults);
|
|
2438
|
+
}
|
|
2439
|
+
} else {
|
|
2440
|
+
for (const tc of response.toolCalls) {
|
|
2441
|
+
const ok = await this.passesFirewall(tc.name, tc.arguments, tc.id, toolResults);
|
|
2442
|
+
if (!ok) continue;
|
|
2443
|
+
try {
|
|
2444
|
+
const content = await this.executeSingleToolCall(tc);
|
|
2445
|
+
toolResults.push({ toolCallId: tc.id, content });
|
|
2446
|
+
} catch (e) {
|
|
2447
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2448
|
+
const isTimeout = e instanceof LemuraToolTimeoutError;
|
|
2449
|
+
this.logger.error(`Tool ${tc.name} ${isTimeout ? "timed out" : "failed"}: ${msg}`, {
|
|
2450
|
+
problem: `Tool ${tc.name} ${isTimeout ? "timed out" : "failed to execute"}.`,
|
|
2451
|
+
hints: isTimeout ? ["Increase toolRegistryTimeoutMs or optimise the tool implementation."] : ["Check the tool parameters and ensure required services are running."]
|
|
2452
|
+
});
|
|
2453
|
+
toolResults.push({ toolCallId: tc.id, content: `Error: ${msg}` });
|
|
2454
|
+
}
|
|
1165
2455
|
}
|
|
1166
2456
|
}
|
|
1167
2457
|
const assistantTurn = {
|
|
1168
2458
|
role: "assistant",
|
|
1169
2459
|
content: response.content || "",
|
|
1170
2460
|
tokenCount: this.adapter.estimateTokens(response.content || "") + 50,
|
|
1171
|
-
// rough tool estimate
|
|
1172
2461
|
turnIndex: this.context.turns.length,
|
|
1173
2462
|
compressed: false,
|
|
1174
2463
|
toolCalls: response.toolCalls
|
|
@@ -1206,61 +2495,194 @@ var SessionManager = class {
|
|
|
1206
2495
|
const maxItsErr = new LemuraMaxIterationsError(`Exceeded max iterations of ${maxIts}`);
|
|
1207
2496
|
this.logger.fatal(maxItsErr.message, {
|
|
1208
2497
|
problem: "The agent entered an infinite loop or took too many steps to resolve the task.",
|
|
1209
|
-
hints: [
|
|
2498
|
+
hints: [
|
|
2499
|
+
"Increase maxIterations if the task is complex.",
|
|
2500
|
+
"Check if tools are returning consistent results."
|
|
2501
|
+
]
|
|
1210
2502
|
});
|
|
1211
2503
|
throw maxItsErr;
|
|
1212
2504
|
}
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
};
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
2505
|
+
/**
|
|
2506
|
+
* Runs the ReAct loop and streams the final assistant response token-by-token.
|
|
2507
|
+
*
|
|
2508
|
+
* Tool calls within the loop are still executed synchronously (they must complete
|
|
2509
|
+
* before streaming the conclusion). Only the final LLM text output is streamed.
|
|
2510
|
+
*
|
|
2511
|
+
* @param userMessage - The user's message (string or multimodal content blocks)
|
|
2512
|
+
* @returns An `AsyncIterable<string>` of delta tokens from the final response
|
|
2513
|
+
*
|
|
2514
|
+
* @example
|
|
2515
|
+
* for await (const token of session.stream('Tell me a story')) {
|
|
2516
|
+
* process.stdout.write(token);
|
|
2517
|
+
* }
|
|
2518
|
+
*/
|
|
2519
|
+
async *stream(userMessage) {
|
|
2520
|
+
if (this.mcpReady) await this.mcpReady;
|
|
2521
|
+
const userMessageStr = Array.isArray(userMessage) ? "[Multimodal Content]" : userMessage;
|
|
2522
|
+
this.logger.info(`Starting streaming session run`, {
|
|
2523
|
+
model: this.config.model,
|
|
2524
|
+
message: userMessageStr
|
|
2525
|
+
});
|
|
2526
|
+
const systemPrompt = this.buildSystemPrompt(userMessageStr);
|
|
2527
|
+
this.context.turns.push({
|
|
2528
|
+
role: "user",
|
|
2529
|
+
content: userMessage,
|
|
2530
|
+
tokenCount: Array.isArray(userMessage) ? userMessage.length * 50 : this.adapter.estimateTokens(userMessage),
|
|
2531
|
+
turnIndex: this.context.turns.length,
|
|
2532
|
+
compressed: false
|
|
2533
|
+
});
|
|
2534
|
+
const maxIts = this.config.maxIterations || 10;
|
|
2535
|
+
this.iterations = 0;
|
|
2536
|
+
this.stepCounter = new StepCounter(this.config.maxSteps ?? 20);
|
|
2537
|
+
while (this.iterations < maxIts) {
|
|
2538
|
+
this.iterations++;
|
|
2539
|
+
this.logger.debug(`[stream] ReAct Iteration ${this.iterations}/${maxIts}`);
|
|
2540
|
+
this.context = await this.contextManager.prepare(this.context);
|
|
2541
|
+
const messages = this.buildMessages(systemPrompt);
|
|
2542
|
+
if (this.stepCounter.isMaxReached()) {
|
|
2543
|
+
messages.push({
|
|
2544
|
+
role: "system",
|
|
2545
|
+
content: this.stepCounter.getForcedConclusionPrompt() + "\n\n" + FinalResponseFormatter.getRequiredStructure()
|
|
2546
|
+
});
|
|
2547
|
+
}
|
|
2548
|
+
let response;
|
|
2549
|
+
try {
|
|
2550
|
+
response = await this.adapter.complete({
|
|
2551
|
+
model: this.config.model,
|
|
2552
|
+
messages,
|
|
2553
|
+
tools: this.stepCounter.isMaxReached() ? [] : this.toolRegistry.getAll(),
|
|
2554
|
+
maxTokens: 1e3
|
|
2555
|
+
});
|
|
2556
|
+
} catch (err) {
|
|
2557
|
+
this.logger.fatal(`Provider call failed: ${err.message}`);
|
|
2558
|
+
throw err;
|
|
2559
|
+
}
|
|
2560
|
+
if (response.finishReason === "tool_call" && response.toolCalls) {
|
|
2561
|
+
this.logger.info(`[stream] Tool calls: ${response.toolCalls.map((tc) => tc.name).join(", ")}`);
|
|
2562
|
+
this.stepCounter.increment(response.toolCalls.length);
|
|
2563
|
+
const toolResults = [];
|
|
2564
|
+
for (const tc of response.toolCalls) {
|
|
2565
|
+
const ok = await this.passesFirewall(tc.name, tc.arguments, tc.id, toolResults);
|
|
2566
|
+
if (!ok) continue;
|
|
2567
|
+
try {
|
|
2568
|
+
const content = await this.executeSingleToolCall(tc);
|
|
2569
|
+
toolResults.push({ toolCallId: tc.id, content });
|
|
2570
|
+
} catch (e) {
|
|
2571
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
2572
|
+
toolResults.push({ toolCallId: tc.id, content: `Error: ${msg}` });
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
const assistantTurn = {
|
|
2576
|
+
role: "assistant",
|
|
2577
|
+
content: response.content || "",
|
|
2578
|
+
tokenCount: this.adapter.estimateTokens(response.content || "") + 50,
|
|
2579
|
+
turnIndex: this.context.turns.length,
|
|
2580
|
+
compressed: false,
|
|
2581
|
+
toolCalls: response.toolCalls
|
|
2582
|
+
};
|
|
2583
|
+
this.context.turns.push(assistantTurn);
|
|
2584
|
+
if (this.config.onTurn) this.config.onTurn(assistantTurn);
|
|
2585
|
+
for (const res of toolResults) {
|
|
2586
|
+
const toolTurn = {
|
|
2587
|
+
role: "tool",
|
|
2588
|
+
content: res.content,
|
|
2589
|
+
tokenCount: this.adapter.estimateTokens(res.content),
|
|
2590
|
+
turnIndex: this.context.turns.length,
|
|
2591
|
+
compressed: false,
|
|
2592
|
+
toolResults: [res]
|
|
2593
|
+
};
|
|
2594
|
+
this.context.turns.push(toolTurn);
|
|
2595
|
+
if (this.config.onTurn) this.config.onTurn(toolTurn);
|
|
2596
|
+
}
|
|
2597
|
+
continue;
|
|
2598
|
+
}
|
|
2599
|
+
this.context = await this.contextManager.prepare(this.context);
|
|
2600
|
+
const finalMessages = this.buildMessages(systemPrompt);
|
|
2601
|
+
let accumulated = "";
|
|
2602
|
+
let finalFinishReason = void 0;
|
|
2603
|
+
let finalTokenCount = 0;
|
|
2604
|
+
for await (const chunk of this.adapter.stream({
|
|
2605
|
+
model: this.config.model,
|
|
2606
|
+
messages: finalMessages,
|
|
2607
|
+
maxTokens: 1e3,
|
|
2608
|
+
stream: true
|
|
2609
|
+
})) {
|
|
2610
|
+
if (chunk.delta) {
|
|
2611
|
+
accumulated += chunk.delta;
|
|
2612
|
+
finalTokenCount += Math.ceil(chunk.delta.length / 4);
|
|
2613
|
+
yield chunk.delta;
|
|
2614
|
+
}
|
|
2615
|
+
if (chunk.finished) {
|
|
2616
|
+
finalFinishReason = chunk.finishReason;
|
|
2617
|
+
if (chunk.usage) {
|
|
2618
|
+
this.totalTokens += chunk.usage.totalTokens;
|
|
2619
|
+
}
|
|
2620
|
+
this.emitTrace("thinking", "llm_stream_finished", {
|
|
2621
|
+
usage: chunk.usage,
|
|
2622
|
+
totalTokens: this.totalTokens,
|
|
2623
|
+
finishReason: chunk.finishReason
|
|
2624
|
+
});
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
const finalTurn = {
|
|
2628
|
+
role: "assistant",
|
|
2629
|
+
content: accumulated,
|
|
2630
|
+
tokenCount: finalTokenCount,
|
|
2631
|
+
turnIndex: this.context.turns.length,
|
|
2632
|
+
compressed: false
|
|
2633
|
+
};
|
|
2634
|
+
this.context.turns.push(finalTurn);
|
|
2635
|
+
if (this.config.onTurn) this.config.onTurn(finalTurn);
|
|
2636
|
+
this.logger.info(`[stream] Streaming run completed (finishReason: ${finalFinishReason ?? "stop"})`);
|
|
2637
|
+
return;
|
|
1241
2638
|
}
|
|
1242
|
-
|
|
2639
|
+
const maxItsErr = new LemuraMaxIterationsError(`Exceeded max iterations of ${maxIts}`);
|
|
2640
|
+
this.logger.fatal(maxItsErr.message, {
|
|
2641
|
+
problem: "The streaming agent loop exceeded its max iterations.",
|
|
2642
|
+
hints: ["Increase maxIterations or reduce task complexity."]
|
|
2643
|
+
});
|
|
2644
|
+
throw maxItsErr;
|
|
1243
2645
|
}
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
this.
|
|
2646
|
+
/**
|
|
2647
|
+
* Resets the session: clears conversation history, resets iteration counters,
|
|
2648
|
+
* and resets tool execution budgets. The adapter, config, and tools are retained.
|
|
2649
|
+
*/
|
|
2650
|
+
reset() {
|
|
2651
|
+
this.context = {
|
|
2652
|
+
systemPrompt: this.config.systemPrompt || "",
|
|
2653
|
+
scratchpad: "",
|
|
2654
|
+
turns: [],
|
|
2655
|
+
tokenCount: 0,
|
|
2656
|
+
maxTokens: this.config.maxTokens,
|
|
2657
|
+
metadata: {}
|
|
2658
|
+
};
|
|
2659
|
+
this.iterations = 0;
|
|
2660
|
+
this.totalToolCallCount = 0;
|
|
2661
|
+
this.perToolCallCount.clear();
|
|
2662
|
+
this.stepCounter = new StepCounter(this.config.maxSteps ?? 20);
|
|
2663
|
+
this.goalInjector = null;
|
|
2664
|
+
this.logger.debug("Session reset");
|
|
1250
2665
|
}
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
2666
|
+
/**
|
|
2667
|
+
* Closes the session and disconnects all MCP servers.
|
|
2668
|
+
*
|
|
2669
|
+
* Call this when you are done with the session and have registered MCP servers,
|
|
2670
|
+
* to ensure child processes are terminated and HTTP connections are released.
|
|
2671
|
+
*
|
|
2672
|
+
* @example
|
|
2673
|
+
* const session = new SessionManager({ ..., mcpServers: [...] });
|
|
2674
|
+
* try {
|
|
2675
|
+
* await session.run('Hello');
|
|
2676
|
+
* } finally {
|
|
2677
|
+
* await session.close();
|
|
2678
|
+
* }
|
|
2679
|
+
*/
|
|
2680
|
+
async close() {
|
|
2681
|
+
if (this.mcpRegistry) {
|
|
2682
|
+
await this.mcpRegistry.disconnectAll();
|
|
2683
|
+
this.emitTrace("system", "mcp_disconnected", {});
|
|
2684
|
+
this.logger.debug("All MCP servers disconnected");
|
|
1262
2685
|
}
|
|
1263
|
-
return prompt;
|
|
1264
2686
|
}
|
|
1265
2687
|
};
|
|
1266
2688
|
|
|
@@ -1295,54 +2717,6 @@ var ContinuationPlanner = class {
|
|
|
1295
2717
|
}
|
|
1296
2718
|
};
|
|
1297
2719
|
|
|
1298
|
-
|
|
1299
|
-
var StepCounter = class {
|
|
1300
|
-
constructor(maxSteps = 20) {
|
|
1301
|
-
this.maxSteps = maxSteps;
|
|
1302
|
-
}
|
|
1303
|
-
toolCallCount = 0;
|
|
1304
|
-
increment(count = 1) {
|
|
1305
|
-
this.toolCallCount += count;
|
|
1306
|
-
}
|
|
1307
|
-
get count() {
|
|
1308
|
-
return this.toolCallCount;
|
|
1309
|
-
}
|
|
1310
|
-
isMaxReached() {
|
|
1311
|
-
return this.toolCallCount >= this.maxSteps;
|
|
1312
|
-
}
|
|
1313
|
-
getForcedConclusionPrompt() {
|
|
1314
|
-
return `You have used ${this.toolCallCount}/${this.maxSteps} steps. Provide your final response now.
|
|
1315
|
-
Do not call any more tools. Use the required structure below.`;
|
|
1316
|
-
}
|
|
1317
|
-
};
|
|
1318
|
-
|
|
1319
|
-
// src/agent/execution/FinalResponseFormatter.ts
|
|
1320
|
-
var FinalResponseFormatter = class {
|
|
1321
|
-
static getRequiredStructure() {
|
|
1322
|
-
return `## Goal Status: [ACHIEVED | PARTIALLY_ACHIEVED | FAILED]
|
|
1323
|
-
|
|
1324
|
-
### What was accomplished
|
|
1325
|
-
[Summary of completed work]
|
|
1326
|
-
|
|
1327
|
-
### Remaining tasks
|
|
1328
|
-
[Bulleted list, or "None"]
|
|
1329
|
-
|
|
1330
|
-
### Failed steps
|
|
1331
|
-
[Tool/step name + error context, or "None"]
|
|
1332
|
-
|
|
1333
|
-
### Result
|
|
1334
|
-
[The actual answer or deliverable]`;
|
|
1335
|
-
}
|
|
1336
|
-
static validateStructure(response) {
|
|
1337
|
-
if (!response.includes("## Goal Status:")) return false;
|
|
1338
|
-
if (!response.includes("### What was accomplished")) return false;
|
|
1339
|
-
if (!response.includes("### Remaining tasks")) return false;
|
|
1340
|
-
if (!response.includes("### Failed steps")) return false;
|
|
1341
|
-
if (!response.includes("### Result")) return false;
|
|
1342
|
-
return true;
|
|
1343
|
-
}
|
|
1344
|
-
};
|
|
1345
|
-
|
|
1346
|
-
export { ContextManager, ContinuationPlanner, DefaultLogger, FinalResponseFormatter, GoalInjector, HistoryCompressionStrategy, InMemoryRAGAdapter, InMemoryStorageAdapter, LemuraAdapterError, LemuraContextOverflowError, LemuraError, LemuraMaxIterationsError, LemuraSkillInjectionError, LemuraToolNotFoundError, LemuraToolTimeoutError, LemuraToolValidationError, LogLevel, OpenAICompatibleAdapter, SandwichCompressionStrategy, ScratchpadStrategy, SessionManager, ShortTermMemoryRegistry, SkillInjector, StepCounter, ToolRegistry, ToolResponseProcessor };
|
|
2720
|
+
export { ContextManager, ContinuationPlanner, DefaultLogger, FinalResponseFormatter, GoalInjector, HistoryCompressionStrategy, InMemoryRAGAdapter, InMemoryStorageAdapter, LemuraAdapterError, LemuraContextOverflowError, LemuraError, LemuraMCPConnectionError, LemuraMCPError, LemuraMCPTimeoutError, LemuraMaxIterationsError, LemuraSkillInjectionError, LemuraToolNotFoundError, LemuraToolTimeoutError, LemuraToolValidationError, LogLevel, MCPClient, MCPClientRegistry, MediaBridge, OpenAICompatibleAdapter, SandwichCompressionStrategy, ScratchpadStrategy, SessionManager, ShortTermMemoryRegistry, SkillInjector, StepCounter, ToolRegistry, ToolResponseProcessor, evaluateToolFirewall, validateJsonSchema };
|
|
1347
2721
|
//# sourceMappingURL=index.mjs.map
|
|
1348
2722
|
//# sourceMappingURL=index.mjs.map
|