inlumen-agents 1.0.0b6.dev0__py3-none-any.whl
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.
- inlumen/__init__.py +1 -0
- inlumen/codegen/__init__.py +6 -0
- inlumen/codegen/context.py +322 -0
- inlumen/codegen/generation_budget.py +36 -0
- inlumen/codegen/generator.py +2887 -0
- inlumen/codegen/llm.py +418 -0
- inlumen/codegen/model.py +72 -0
- inlumen/codegen/model_plans.py +364 -0
- inlumen/codegen/multimodal.py +141 -0
- inlumen/codegen/pipeline_compiler.py +970 -0
- inlumen/codegen/resource_policy.py +288 -0
- inlumen/codegen/run.py +183 -0
- inlumen/codegen/sandbox.py +1678 -0
- inlumen/codegen/schemas.py +414 -0
- inlumen/codegen/task_profiles.py +802 -0
- inlumen/codegen/trusted_adapters.py +1197 -0
- inlumen/codegen/validation.py +187 -0
- inlumen/codegen/workspace.py +10 -0
- inlumen/contracts/__init__.py +25 -0
- inlumen/contracts/artifact_contract.py +135 -0
- inlumen/contracts/artifact_runtime.py +515 -0
- inlumen/contracts/connector_catalog.py +114 -0
- inlumen/contracts/graph_records.py +273 -0
- inlumen/contracts/map_scope.py +200 -0
- inlumen/contracts/model_plans.py +896 -0
- inlumen/contracts/node_definitions/__init__.py +11 -0
- inlumen/contracts/node_definitions/artifacts.py +92 -0
- inlumen/contracts/node_definitions/instance.py +212 -0
- inlumen/contracts/node_definitions/manifests/core.json +190 -0
- inlumen/contracts/node_definitions/node-definition.schema.json +123 -0
- inlumen/contracts/node_definitions/registry.py +103 -0
- inlumen/contracts/node_definitions/schema.py +141 -0
- inlumen/contracts/node_parameters.py +69 -0
- inlumen/contracts/node_ports.py +209 -0
- inlumen/contracts/pipeline_graph_validation.py +673 -0
- inlumen/contracts/plugin-manifest.schema.json +229 -0
- inlumen/contracts/plugin_manifest.py +364 -0
- inlumen/contracts/plugins.py +173 -0
- inlumen/contracts/port_types.json +310 -0
- inlumen/contracts/port_types.py +254 -0
- inlumen/contracts/project_ir.py +109 -0
- inlumen/contracts/step_types.py +115 -0
- inlumen/contracts/subpipeline_reference.py +650 -0
- inlumen/contracts/task_package_contract.py +304 -0
- inlumen/contracts/task_package_spec.json +211 -0
- inlumen/design/__init__.py +9 -0
- inlumen/design/catalog.py +47 -0
- inlumen/design/context.py +411 -0
- inlumen/design/contract.py +91 -0
- inlumen/design/guardrails.py +461 -0
- inlumen/design/memory_store.py +956 -0
- inlumen/design/prompt.py +206 -0
- inlumen/design/run.py +218 -0
- inlumen/design/store.py +211 -0
- inlumen/design/store_contract.py +268 -0
- inlumen/design/team.py +48 -0
- inlumen/design/tools.py +1140 -0
- inlumen/design/turn.py +114 -0
- inlumen_agents-1.0.0b6.dev0.dist-info/METADATA +106 -0
- inlumen_agents-1.0.0b6.dev0.dist-info/RECORD +63 -0
- inlumen_agents-1.0.0b6.dev0.dist-info/WHEEL +5 -0
- inlumen_agents-1.0.0b6.dev0.dist-info/licenses/LICENSE +202 -0
- inlumen_agents-1.0.0b6.dev0.dist-info/top_level.txt +1 -0
inlumen/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""inLUMEN's pipeline design and code generation agents, as a library."""
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""inLUMEN's code generator: turns a pipeline graph into runnable Task packages.
|
|
2
|
+
|
|
3
|
+
Other programs call :func:`inlumen.codegen.run.generate`; the codegen service
|
|
4
|
+
calls :func:`inlumen.codegen.generator.generate_pipeline_script_bundles`
|
|
5
|
+
directly. Both run the same planning, generation, repair and validation.
|
|
6
|
+
"""
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Build the code generator's input from a canvas graph.
|
|
2
|
+
|
|
3
|
+
The canvas graph is what the editor, ``inlumen.design.run.design()`` and the
|
|
4
|
+
Project JSON import produce. Where attached files live is the caller's
|
|
5
|
+
business: pass ``bucket_for`` to name a step's storage bucket and ``sample`` to
|
|
6
|
+
describe a file's contents (the inLUMEN backend reads MinIO; a library caller
|
|
7
|
+
can use :func:`directory_sampler`).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import base64
|
|
12
|
+
import csv
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from io import StringIO
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from inlumen.contracts.artifact_contract import classify_artifact
|
|
21
|
+
from inlumen.contracts.node_definitions.artifacts import implementation_plan_from_data
|
|
22
|
+
from inlumen.contracts.node_ports import normalize_node_ports
|
|
23
|
+
from inlumen.contracts.step_types import normalize_step_type
|
|
24
|
+
from inlumen.contracts.task_package_contract import TASK_METADATA_FILENAME
|
|
25
|
+
|
|
26
|
+
PIPELINE_CONTEXT_SCHEMA_VERSION = "inlumen.pipeline-script-generation-context@1"
|
|
27
|
+
|
|
28
|
+
DEFAULT_ALLOWED_PACKAGES = [
|
|
29
|
+
"pandas",
|
|
30
|
+
"numpy",
|
|
31
|
+
"pillow",
|
|
32
|
+
"scikit-learn",
|
|
33
|
+
"requests",
|
|
34
|
+
"pypdf",
|
|
35
|
+
"SpeechRecognition",
|
|
36
|
+
"pocketsphinx",
|
|
37
|
+
"textblob",
|
|
38
|
+
]
|
|
39
|
+
CODEGEN_RUNTIME_FILENAMES = {
|
|
40
|
+
"main.py",
|
|
41
|
+
"requirements.txt",
|
|
42
|
+
TASK_METADATA_FILENAME,
|
|
43
|
+
"node-manifest.json",
|
|
44
|
+
"validation-report.json",
|
|
45
|
+
}
|
|
46
|
+
DEFAULT_SAMPLE_BINARY_MAX_BYTES = 16 * 1024 * 1024
|
|
47
|
+
SAMPLE_TEXT_MAX_CHARS = 12000
|
|
48
|
+
|
|
49
|
+
# (bucket, filename, entry) -> fields to merge into the file entry.
|
|
50
|
+
Sampler = Callable[[str, str, dict[str, Any]], dict[str, Any]]
|
|
51
|
+
BucketNamer = Callable[[str], str]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def local_bucket_name(flow_id: str) -> str:
|
|
55
|
+
"""The bucket a step's files use in a local (single-workspace) inLUMEN."""
|
|
56
|
+
return f"files-step-id-{str(flow_id or '').strip().lower()}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def node_data(node: dict[str, Any]) -> dict[str, Any]:
|
|
60
|
+
data = node.get("data")
|
|
61
|
+
return data if isinstance(data, dict) else node
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def is_codegen_runtime_file(filename: str) -> bool:
|
|
65
|
+
"""Whether a file is generated code rather than data the step reads."""
|
|
66
|
+
lower = str(filename or "").strip().lower()
|
|
67
|
+
return (
|
|
68
|
+
lower in CODEGEN_RUNTIME_FILENAMES
|
|
69
|
+
or lower.startswith("dockerfile.")
|
|
70
|
+
or lower.endswith((".py", ".pyi", ".sh", ".bash", ".js", ".mjs", ".cjs", ".ts", ".tsx"))
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def sample_from_bytes(
|
|
75
|
+
content: bytes,
|
|
76
|
+
descriptor: dict[str, Any],
|
|
77
|
+
*,
|
|
78
|
+
binary_max_bytes: int = DEFAULT_SAMPLE_BINARY_MAX_BYTES,
|
|
79
|
+
) -> dict[str, Any]:
|
|
80
|
+
"""Describe a file's contents for the model and the validation sandbox.
|
|
81
|
+
|
|
82
|
+
Tables and JSON get columns and up to ten rows, text its first 12000
|
|
83
|
+
characters; binary files travel whole (base64) up to ``binary_max_bytes``.
|
|
84
|
+
"""
|
|
85
|
+
kind = descriptor.get("kind")
|
|
86
|
+
file_format = descriptor.get("format")
|
|
87
|
+
if kind not in {"table", "json", "text"}:
|
|
88
|
+
if len(content) > binary_max_bytes:
|
|
89
|
+
return {
|
|
90
|
+
"size_bytes": len(content),
|
|
91
|
+
"sample_transport_error": (
|
|
92
|
+
f"Real input exceeds the {binary_max_bytes}-byte "
|
|
93
|
+
"binary validation transport limit."
|
|
94
|
+
),
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
"size_bytes": len(content),
|
|
98
|
+
"sample": {
|
|
99
|
+
"content_base64": base64.b64encode(content).decode("ascii"),
|
|
100
|
+
"content_sha256": hashlib.sha256(content).hexdigest(),
|
|
101
|
+
"omitted_bytes": 0,
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
text = content[:SAMPLE_TEXT_MAX_CHARS].decode("utf-8", errors="replace")
|
|
105
|
+
omitted = max(0, len(content) - len(content[:SAMPLE_TEXT_MAX_CHARS]))
|
|
106
|
+
if kind == "table" and file_format in {"csv", "tsv"}:
|
|
107
|
+
delimiter = "\t" if file_format == "tsv" else ","
|
|
108
|
+
try:
|
|
109
|
+
reader = csv.DictReader(StringIO(text), delimiter=delimiter)
|
|
110
|
+
rows = [row for _, row in zip(range(10), reader)]
|
|
111
|
+
columns = list(reader.fieldnames or [])
|
|
112
|
+
except Exception:
|
|
113
|
+
return {"sample": {"text": text, "omitted_bytes": omitted}}
|
|
114
|
+
return {
|
|
115
|
+
"columns": columns,
|
|
116
|
+
"sample": {
|
|
117
|
+
"rows": rows,
|
|
118
|
+
"omitted_bytes": omitted,
|
|
119
|
+
},
|
|
120
|
+
"size_bytes": len(content),
|
|
121
|
+
}
|
|
122
|
+
if kind == "json":
|
|
123
|
+
try:
|
|
124
|
+
parsed = json.loads(text)
|
|
125
|
+
except Exception:
|
|
126
|
+
parsed = None
|
|
127
|
+
rows = []
|
|
128
|
+
columns = []
|
|
129
|
+
if isinstance(parsed, list):
|
|
130
|
+
rows = [item for item in parsed[:10] if isinstance(item, dict)]
|
|
131
|
+
elif isinstance(parsed, dict):
|
|
132
|
+
candidate_rows = parsed.get("records") or parsed.get("items")
|
|
133
|
+
if isinstance(candidate_rows, list):
|
|
134
|
+
rows = [item for item in candidate_rows[:10] if isinstance(item, dict)]
|
|
135
|
+
if rows:
|
|
136
|
+
columns = sorted({key for row in rows for key in row})
|
|
137
|
+
return {
|
|
138
|
+
**({"columns": columns} if columns else {}),
|
|
139
|
+
"sample": {
|
|
140
|
+
**({"rows": rows} if rows else {"text": text}),
|
|
141
|
+
"omitted_bytes": omitted,
|
|
142
|
+
},
|
|
143
|
+
"size_bytes": len(content),
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
"sample": {"text": text, "omitted_bytes": omitted},
|
|
147
|
+
"size_bytes": len(content),
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def directory_sampler(
|
|
152
|
+
root: str | Path,
|
|
153
|
+
*,
|
|
154
|
+
binary_max_bytes: int = DEFAULT_SAMPLE_BINARY_MAX_BYTES,
|
|
155
|
+
) -> Sampler:
|
|
156
|
+
"""Sample attached files from a local folder.
|
|
157
|
+
|
|
158
|
+
A file attached to a step is looked up as ``root/<flow_id>/<filename>``
|
|
159
|
+
(bucket ``files-step-id-<flow_id>``), then as ``root/<filename>``. Missing
|
|
160
|
+
files are described by name only, as when storage is unavailable.
|
|
161
|
+
"""
|
|
162
|
+
base = Path(root)
|
|
163
|
+
|
|
164
|
+
def sample(bucket: str, filename: str, entry: dict[str, Any]) -> dict[str, Any]:
|
|
165
|
+
flow_id = bucket.removeprefix("files-step-id-")
|
|
166
|
+
for candidate in (base / flow_id / filename, base / filename):
|
|
167
|
+
if candidate.is_file():
|
|
168
|
+
return sample_from_bytes(candidate.read_bytes(), entry, binary_max_bytes=binary_max_bytes)
|
|
169
|
+
return {}
|
|
170
|
+
|
|
171
|
+
return sample
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def node_file_entries(
|
|
175
|
+
node: dict[str, Any],
|
|
176
|
+
*,
|
|
177
|
+
bucket_for: BucketNamer = local_bucket_name,
|
|
178
|
+
sample: Sampler | None = None,
|
|
179
|
+
include_runtime_artifacts: bool = False,
|
|
180
|
+
) -> list[dict[str, Any]]:
|
|
181
|
+
"""The data files attached to a step, sampled when ``sample`` is given."""
|
|
182
|
+
data = node_data(node)
|
|
183
|
+
raw_files = (
|
|
184
|
+
data.get("file_buckets")
|
|
185
|
+
if isinstance(data.get("file_buckets"), list)
|
|
186
|
+
else data.get("files")
|
|
187
|
+
)
|
|
188
|
+
if not isinstance(raw_files, list):
|
|
189
|
+
return []
|
|
190
|
+
flow_id = str(node.get("id") or data.get("flow_id") or data.get("id") or "").strip()
|
|
191
|
+
default_bucket = bucket_for(flow_id)
|
|
192
|
+
entries = []
|
|
193
|
+
for item in raw_files:
|
|
194
|
+
if isinstance(item, str):
|
|
195
|
+
filename = item.strip()
|
|
196
|
+
bucket = default_bucket
|
|
197
|
+
content_type = None
|
|
198
|
+
elif isinstance(item, dict):
|
|
199
|
+
filename = str(item.get("filename") or item.get("name") or "").strip()
|
|
200
|
+
bucket = str(item.get("bucket") or default_bucket).strip().lower()
|
|
201
|
+
content_type = item.get("content_type")
|
|
202
|
+
else:
|
|
203
|
+
continue
|
|
204
|
+
if not filename:
|
|
205
|
+
continue
|
|
206
|
+
if not include_runtime_artifacts and is_codegen_runtime_file(filename):
|
|
207
|
+
continue
|
|
208
|
+
entry = {
|
|
209
|
+
"filename": filename,
|
|
210
|
+
"bucket": bucket,
|
|
211
|
+
"content_type": content_type,
|
|
212
|
+
"role": item.get("role") if isinstance(item, dict) else None,
|
|
213
|
+
**classify_artifact(filename),
|
|
214
|
+
}
|
|
215
|
+
if isinstance(item, dict) and item.get("snapshot_bucket") and item.get("snapshot_object"):
|
|
216
|
+
entry["snapshot_bucket"] = item["snapshot_bucket"]
|
|
217
|
+
entry["snapshot_object"] = item["snapshot_object"]
|
|
218
|
+
if sample is not None:
|
|
219
|
+
entry.update(sample(
|
|
220
|
+
entry.get("snapshot_bucket", bucket), entry.get("snapshot_object", filename), entry
|
|
221
|
+
))
|
|
222
|
+
entries.append(entry)
|
|
223
|
+
return entries
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def node_descriptor(
|
|
227
|
+
node: dict[str, Any],
|
|
228
|
+
*,
|
|
229
|
+
bucket_for: BucketNamer = local_bucket_name,
|
|
230
|
+
sample: Sampler | None = None,
|
|
231
|
+
) -> dict[str, Any]:
|
|
232
|
+
"""One canvas step as the generator's ``NodeDescriptor`` (secrets left out)."""
|
|
233
|
+
data = node_data(node)
|
|
234
|
+
parameters = (
|
|
235
|
+
dict(data.get("param"))
|
|
236
|
+
if isinstance(data.get("param"), dict)
|
|
237
|
+
else {}
|
|
238
|
+
)
|
|
239
|
+
secret_parameter_names = {
|
|
240
|
+
str(name)
|
|
241
|
+
for name in (data.get("secret_params") or [])
|
|
242
|
+
if str(name).strip()
|
|
243
|
+
}
|
|
244
|
+
parameters = {
|
|
245
|
+
key: value
|
|
246
|
+
for key, value in parameters.items()
|
|
247
|
+
if str(key) not in secret_parameter_names
|
|
248
|
+
}
|
|
249
|
+
content = data.get("content")
|
|
250
|
+
if isinstance(content, str) and content.strip():
|
|
251
|
+
parameters["content"] = content.strip()
|
|
252
|
+
implementation = implementation_plan_from_data(data)
|
|
253
|
+
subpipeline = data.get("subpipeline")
|
|
254
|
+
if not isinstance(subpipeline, dict):
|
|
255
|
+
try:
|
|
256
|
+
subpipeline = json.loads(data.get("subpipeline_json") or "{}")
|
|
257
|
+
except (TypeError, ValueError):
|
|
258
|
+
subpipeline = {}
|
|
259
|
+
return {
|
|
260
|
+
"flow_id": str(node.get("id") or data.get("flow_id") or data.get("id") or ""),
|
|
261
|
+
"label": str(data.get("label") or ""),
|
|
262
|
+
"description": str(data.get("description") or ""),
|
|
263
|
+
"type": normalize_step_type(data.get("type")),
|
|
264
|
+
"template": str(data.get("template_label") or data.get("definition_id") or ""),
|
|
265
|
+
"parameters": parameters,
|
|
266
|
+
"secret_parameters": sorted(secret_parameter_names),
|
|
267
|
+
"implementation": implementation,
|
|
268
|
+
"subpipeline": subpipeline if isinstance(subpipeline, dict) else {},
|
|
269
|
+
"ports": normalize_node_ports(data.get("ports"), data.get("type")),
|
|
270
|
+
"files": node_file_entries(node, bucket_for=bucket_for, sample=sample),
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def default_runtime_constraints() -> dict[str, Any]:
|
|
275
|
+
"""The runtime inLUMEN generates pipelines for."""
|
|
276
|
+
return {
|
|
277
|
+
"language": "python",
|
|
278
|
+
"python_version": "3.11",
|
|
279
|
+
"base_image": "python:3.11-slim",
|
|
280
|
+
"allowed_packages": list(DEFAULT_ALLOWED_PACKAGES),
|
|
281
|
+
"allow_unlisted_model_packages": True,
|
|
282
|
+
"network_allowed": True,
|
|
283
|
+
"max_runtime_seconds": 900,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def pipeline_context(
|
|
288
|
+
graph: dict[str, Any],
|
|
289
|
+
*,
|
|
290
|
+
bucket_for: BucketNamer = local_bucket_name,
|
|
291
|
+
sample: Sampler | None = None,
|
|
292
|
+
) -> dict[str, Any]:
|
|
293
|
+
"""A canvas graph as a ``PipelineGenerationContext`` payload."""
|
|
294
|
+
nodes = graph.get("nodes") if isinstance(graph.get("nodes"), list) else []
|
|
295
|
+
edges = graph.get("edges") if isinstance(graph.get("edges"), list) else []
|
|
296
|
+
descriptors = [
|
|
297
|
+
node_descriptor(node, bucket_for=bucket_for, sample=sample)
|
|
298
|
+
for node in nodes
|
|
299
|
+
if isinstance(node, dict)
|
|
300
|
+
]
|
|
301
|
+
return {
|
|
302
|
+
"schema_version": PIPELINE_CONTEXT_SCHEMA_VERSION,
|
|
303
|
+
"pipeline": graph.get("pipeline") if isinstance(graph.get("pipeline"), dict) else {},
|
|
304
|
+
"graph": {
|
|
305
|
+
"nodes": descriptors,
|
|
306
|
+
"edges": [
|
|
307
|
+
{
|
|
308
|
+
"source": str(edge.get("source")),
|
|
309
|
+
"target": str(edge.get("target")),
|
|
310
|
+
"source_port": str(
|
|
311
|
+
edge.get("sourceHandle") or edge.get("source_port") or ""
|
|
312
|
+
),
|
|
313
|
+
"target_port": str(
|
|
314
|
+
edge.get("targetHandle") or edge.get("target_port") or ""
|
|
315
|
+
),
|
|
316
|
+
}
|
|
317
|
+
for edge in edges
|
|
318
|
+
if isinstance(edge, dict) and edge.get("source") and edge.get("target")
|
|
319
|
+
],
|
|
320
|
+
},
|
|
321
|
+
"runtime_constraints": default_runtime_constraints(),
|
|
322
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Request-scoped limits shared by generation and all nested repair calls."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import functools
|
|
4
|
+
from contextvars import ContextVar
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class GenerationBudget:
|
|
10
|
+
max_requests: int
|
|
11
|
+
max_reported_cost_usd: float
|
|
12
|
+
requests: int = 0
|
|
13
|
+
reported_cost_usd: float = 0
|
|
14
|
+
|
|
15
|
+
def reserve(self):
|
|
16
|
+
if self.requests >= self.max_requests or self.reported_cost_usd >= self.max_reported_cost_usd:
|
|
17
|
+
raise ValueError('Generation budget exhausted. Review the partial result before retrying.')
|
|
18
|
+
self.requests += 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
CURRENT_BUDGET: ContextVar[GenerationBudget | None] = ContextVar('generation_budget', default=None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def budgeted(function):
|
|
25
|
+
@functools.wraps(function)
|
|
26
|
+
async def wrapper(request, *args, **kwargs):
|
|
27
|
+
if CURRENT_BUDGET.get() is not None:
|
|
28
|
+
return await function(request, *args, **kwargs)
|
|
29
|
+
options = request.options
|
|
30
|
+
token = CURRENT_BUDGET.set(GenerationBudget(options.max_llm_requests, options.max_reported_cost_usd))
|
|
31
|
+
try:
|
|
32
|
+
async with asyncio.timeout(options.max_generation_seconds):
|
|
33
|
+
return await function(request, *args, **kwargs)
|
|
34
|
+
finally:
|
|
35
|
+
CURRENT_BUDGET.reset(token)
|
|
36
|
+
return wrapper
|