arms-agent 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. arms_agent-1.0.0/.gitignore +12 -0
  2. arms_agent-1.0.0/LICENSE +26 -0
  3. arms_agent-1.0.0/PKG-INFO +149 -0
  4. arms_agent-1.0.0/README.md +118 -0
  5. arms_agent-1.0.0/pyproject.toml +93 -0
  6. arms_agent-1.0.0/src/arms_agent/__init__.py +13 -0
  7. arms_agent-1.0.0/src/arms_agent/__main__.py +127 -0
  8. arms_agent-1.0.0/src/arms_agent/agent.py +138 -0
  9. arms_agent-1.0.0/src/arms_agent/cache.py +218 -0
  10. arms_agent-1.0.0/src/arms_agent/logging_config.py +99 -0
  11. arms_agent-1.0.0/src/arms_agent/prompts.py +181 -0
  12. arms_agent-1.0.0/src/arms_agent/schema.py +145 -0
  13. arms_agent-1.0.0/src/arms_agent/state.py +24 -0
  14. arms_agent-1.0.0/src/arms_agent/token_tracker/__init__.py +33 -0
  15. arms_agent-1.0.0/src/arms_agent/token_tracker/pricing.py +133 -0
  16. arms_agent-1.0.0/src/arms_agent/token_tracker/tracker.py +67 -0
  17. arms_agent-1.0.0/src/arms_agent/token_tracker/usage.py +107 -0
  18. arms_agent-1.0.0/src/arms_agent/tools.py +214 -0
  19. arms_agent-1.0.0/src/arms_agent/tracing/__init__.py +53 -0
  20. arms_agent-1.0.0/src/arms_agent/tracing/client.py +76 -0
  21. arms_agent-1.0.0/src/arms_agent/tracing/instrumentation.py +52 -0
  22. arms_agent-1.0.0/src/arms_agent/tracing/observations.py +101 -0
  23. arms_agent-1.0.0/src/arms_agent/tracing/spans.py +33 -0
  24. arms_agent-1.0.0/src/arms_agent/utils.py +241 -0
  25. arms_agent-1.0.0/src/arms_agent/workflow.py +43 -0
  26. arms_agent-1.0.0/tests/conftest.py +51 -0
  27. arms_agent-1.0.0/tests/test_agent.py +19 -0
  28. arms_agent-1.0.0/tests/test_cache.py +121 -0
  29. arms_agent-1.0.0/tests/test_migrate_cli.py +160 -0
  30. arms_agent-1.0.0/tests/test_prompts.py +46 -0
  31. arms_agent-1.0.0/tests/test_response_format.py +300 -0
  32. arms_agent-1.0.0/tests/test_schema.py +375 -0
  33. arms_agent-1.0.0/tests/test_token_tracker.py +454 -0
  34. arms_agent-1.0.0/tests/test_tools.py +246 -0
  35. arms_agent-1.0.0/tests/test_tracing.py +356 -0
  36. arms_agent-1.0.0/tests/test_utils.py +245 -0
  37. arms_agent-1.0.0/tests/test_version.py +40 -0
  38. arms_agent-1.0.0/tests/test_workflow.py +21 -0
@@ -0,0 +1,12 @@
1
+ # Package-local ignores. Hatchling ships the nearest VCS ignore file inside the
2
+ # sdist and reads it when choosing files, so this file keeps build artifacts out
3
+ # of the wheel and keeps the workspace's own ignore list from being published.
4
+ __pycache__/
5
+ *.py[cod]
6
+ *$py.class
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .pytest_cache/
11
+ .ruff_cache/
12
+ .coverage
@@ -0,0 +1,26 @@
1
+ Copyright (c) 2026, The Board of Trustees of Leland Stanford Junior University
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without modification, are
5
+ permitted provided that the following conditions are met:
6
+
7
+ 1. Redistributions of source code must retain the above copyright notice, this list of
8
+ conditions and the following disclaimer.
9
+
10
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list
11
+ of conditions and the following disclaimer in the documentation and/or other materials
12
+ provided with the distribution.
13
+
14
+ THIS SOFTWARE IS PROVIDED BY The Board of Trustees of Leland Stanford Junior University
15
+ ''AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
16
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
17
+ EVENT SHALL The Board of Trustees of Leland Stanford Junior University OR CONTRIBUTORS BE
18
+ LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
19
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
20
+ DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
21
+ WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
22
+ IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
23
+
24
+ The views and conclusions contained in the software and documentation are those of the
25
+ authors and should not be interpreted as representing official policies, either expressed
26
+ or implied, of The Board of Trustees of Leland Stanford Junior University.
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.5
2
+ Name: arms-agent
3
+ Version: 1.0.0
4
+ Summary: An ontology-constrained LLM agent that standardizes legacy biomedical metadata into CEDAR templates
5
+ Project-URL: Homepage, https://github.com/musen-lab/metadata-standardization-agent
6
+ Project-URL: Repository, https://github.com/musen-lab/metadata-standardization-agent
7
+ Project-URL: Issues, https://github.com/musen-lab/metadata-standardization-agent/issues
8
+ Project-URL: Paper, https://arxiv.org/abs/2604.08552
9
+ Author: Josef Hardi
10
+ License-Expression: BSD-2-Clause
11
+ License-File: LICENSE
12
+ Keywords: biomedical,cedar,langgraph,llm-agent,metadata,ontology
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
20
+ Requires-Python: >=3.12
21
+ Requires-Dist: cedar-mcp>=1.3.0
22
+ Requires-Dist: langchain-core<2.0,>=1.2
23
+ Requires-Dist: langchain-openai<2.0,>=1.0
24
+ Requires-Dist: langchain<2.0,>=1.2
25
+ Requires-Dist: langgraph<2.0,>=1.0
26
+ Requires-Dist: pydantic<3.0,>=2.0
27
+ Requires-Dist: python-dotenv>=1.2.1
28
+ Provides-Extra: tracing
29
+ Requires-Dist: langfuse<5.0,>=4.14; extra == 'tracing'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # ARMS Agent
33
+
34
+ An LLM agent that standardizes legacy biomedical metadata records into the [CEDAR](https://metadatacenter.org/) template format.
35
+
36
+ It fetches the live CEDAR template and queries BioPortal for canonical terms through [Model Context Protocol](https://www.anthropic.com/news/model-context-protocol) tools, so the constraints it applies are the ones the template holds right now.
37
+
38
+ This is the agent described in *Automated Standardization of Legacy Biomedical Metadata Using an Ontology-Constrained LLM Agent* ([arXiv:2604.08552](https://arxiv.org/abs/2604.08552)). The evaluation harness, the 839-record dataset, and the code for every figure in the paper live in the [project repository](https://github.com/musen-lab/metadata-standardization-agent).
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pip install arms-agent
44
+ ```
45
+
46
+ ## Configure
47
+
48
+ Three keys are required. Put them in the environment, or in a `.env` file in the directory you run from:
49
+
50
+ ```
51
+ OPENAI_API_KEY=... # LLM calls
52
+ CEDAR_API_KEY=... # fetching CEDAR templates
53
+ BIOPORTAL_API_KEY=... # ontology term lookups
54
+ ```
55
+
56
+ Optional: set `OPENAI_BASE_URL` to route LLM calls through an OpenAI-compatible gateway.
57
+
58
+ To trace each LLM call, tool call, and agent step to [Langfuse](https://langfuse.com/), install the extra and
59
+ set both keys:
60
+
61
+ ```bash
62
+ pip install 'arms-agent[tracing]'
63
+ ```
64
+
65
+ ```
66
+ LANGFUSE_PUBLIC_KEY=...
67
+ LANGFUSE_SECRET_KEY=...
68
+ LANGFUSE_HOST=... # optional, defaults to Langfuse Cloud
69
+ ```
70
+
71
+ Tracing stays off until both keys are set, and `LANGFUSE_TRACING_ENABLED=false` switches it off while leaving
72
+ the keys in place.
73
+
74
+ ## Command line
75
+
76
+ ```bash
77
+ arms-migrate \
78
+ --input legacy-record.json \
79
+ --target-schema https://repo.metadatacenter.org/templates/dd5e8653-81cf-470b-b71b-15cab421bb84 \
80
+ --output migrated.json \
81
+ --model gpt-5-mini
82
+ ```
83
+
84
+ `--output` takes a file or a directory. Given a directory, the filename comes from the input; left out, the
85
+ result lands in your temp directory. `--model` defaults to `gpt-5.6-terra`. Add `--debug` for step-by-step
86
+ logging on stderr.
87
+
88
+ ## Python
89
+
90
+ ```python
91
+ import asyncio, json
92
+
93
+ from langchain_core.messages import HumanMessage
94
+
95
+ from arms_agent.agent import build_migration_agent, build_response_format
96
+ from arms_agent.prompts import SYSTEM_PROMPT
97
+ from arms_agent.tools import all_tools
98
+ from arms_agent.workflow import build_workflow
99
+
100
+ template_iri = "https://repo.metadatacenter.org/templates/dd5e8653-81cf-470b-b71b-15cab421bb84"
101
+ legacy = json.load(open("legacy-record.json"))
102
+
103
+ agent = build_migration_agent(
104
+ model="gpt-5-mini",
105
+ system_prompt=SYSTEM_PROMPT,
106
+ response_format=build_response_format(template_iri),
107
+ tools=all_tools,
108
+ reasoning_effort="high",
109
+ )
110
+
111
+ result = asyncio.run(
112
+ build_workflow(agent).ainvoke(
113
+ {
114
+ "messages": [
115
+ HumanMessage(
116
+ content=(
117
+ "Migrate the following legacy metadata record to the CEDAR template.\n\n"
118
+ f"CEDAR Template IRI: {template_iri}\n\n"
119
+ f"Legacy metadata:\n```json\n{json.dumps(legacy, indent=2)}\n```"
120
+ )
121
+ )
122
+ ],
123
+ "cedar_template_iri": template_iri,
124
+ },
125
+ config={"recursion_limit": 30},
126
+ )
127
+ )
128
+ print(json.dumps(result["metadata"], indent=2))
129
+ ```
130
+
131
+ The agent answers against a JSON schema built from the template, so the result conforms to the template's field structure. When a model answers without a validated object, a fixed extraction step parses the text into one.
132
+
133
+ ## Caching
134
+
135
+ CEDAR template and BioPortal term responses are cached in SQLite for 24 hours, to keep repeated runs fast and off the rate limits. Override with `ARMS_CACHE_DIR` and `ARMS_CACHE_TTL_SECONDS`.
136
+
137
+ ## Other settings
138
+
139
+ | Variable | Default | What it does |
140
+ | --- | --- | --- |
141
+ | `OPENAI_EXTRACTION_MODEL` | `gpt-4.1-mini` | The model that parses a reply into an object when the main model answers without one. |
142
+ | `OPENAI_COST_MULTIPLIER` | `1.0` | Scales the reported cost when your endpoint charges a fraction of OpenAI's list prices. |
143
+ | `OPENAI_COST_CACHE_DISCOUNT` | `true` | Whether the endpoint discounts cached input tokens. |
144
+
145
+ Costs are local estimates from provider-reported token counts, not billed amounts.
146
+
147
+ ## License
148
+
149
+ BSD 2-Clause.
@@ -0,0 +1,118 @@
1
+ # ARMS Agent
2
+
3
+ An LLM agent that standardizes legacy biomedical metadata records into the [CEDAR](https://metadatacenter.org/) template format.
4
+
5
+ It fetches the live CEDAR template and queries BioPortal for canonical terms through [Model Context Protocol](https://www.anthropic.com/news/model-context-protocol) tools, so the constraints it applies are the ones the template holds right now.
6
+
7
+ This is the agent described in *Automated Standardization of Legacy Biomedical Metadata Using an Ontology-Constrained LLM Agent* ([arXiv:2604.08552](https://arxiv.org/abs/2604.08552)). The evaluation harness, the 839-record dataset, and the code for every figure in the paper live in the [project repository](https://github.com/musen-lab/metadata-standardization-agent).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pip install arms-agent
13
+ ```
14
+
15
+ ## Configure
16
+
17
+ Three keys are required. Put them in the environment, or in a `.env` file in the directory you run from:
18
+
19
+ ```
20
+ OPENAI_API_KEY=... # LLM calls
21
+ CEDAR_API_KEY=... # fetching CEDAR templates
22
+ BIOPORTAL_API_KEY=... # ontology term lookups
23
+ ```
24
+
25
+ Optional: set `OPENAI_BASE_URL` to route LLM calls through an OpenAI-compatible gateway.
26
+
27
+ To trace each LLM call, tool call, and agent step to [Langfuse](https://langfuse.com/), install the extra and
28
+ set both keys:
29
+
30
+ ```bash
31
+ pip install 'arms-agent[tracing]'
32
+ ```
33
+
34
+ ```
35
+ LANGFUSE_PUBLIC_KEY=...
36
+ LANGFUSE_SECRET_KEY=...
37
+ LANGFUSE_HOST=... # optional, defaults to Langfuse Cloud
38
+ ```
39
+
40
+ Tracing stays off until both keys are set, and `LANGFUSE_TRACING_ENABLED=false` switches it off while leaving
41
+ the keys in place.
42
+
43
+ ## Command line
44
+
45
+ ```bash
46
+ arms-migrate \
47
+ --input legacy-record.json \
48
+ --target-schema https://repo.metadatacenter.org/templates/dd5e8653-81cf-470b-b71b-15cab421bb84 \
49
+ --output migrated.json \
50
+ --model gpt-5-mini
51
+ ```
52
+
53
+ `--output` takes a file or a directory. Given a directory, the filename comes from the input; left out, the
54
+ result lands in your temp directory. `--model` defaults to `gpt-5.6-terra`. Add `--debug` for step-by-step
55
+ logging on stderr.
56
+
57
+ ## Python
58
+
59
+ ```python
60
+ import asyncio, json
61
+
62
+ from langchain_core.messages import HumanMessage
63
+
64
+ from arms_agent.agent import build_migration_agent, build_response_format
65
+ from arms_agent.prompts import SYSTEM_PROMPT
66
+ from arms_agent.tools import all_tools
67
+ from arms_agent.workflow import build_workflow
68
+
69
+ template_iri = "https://repo.metadatacenter.org/templates/dd5e8653-81cf-470b-b71b-15cab421bb84"
70
+ legacy = json.load(open("legacy-record.json"))
71
+
72
+ agent = build_migration_agent(
73
+ model="gpt-5-mini",
74
+ system_prompt=SYSTEM_PROMPT,
75
+ response_format=build_response_format(template_iri),
76
+ tools=all_tools,
77
+ reasoning_effort="high",
78
+ )
79
+
80
+ result = asyncio.run(
81
+ build_workflow(agent).ainvoke(
82
+ {
83
+ "messages": [
84
+ HumanMessage(
85
+ content=(
86
+ "Migrate the following legacy metadata record to the CEDAR template.\n\n"
87
+ f"CEDAR Template IRI: {template_iri}\n\n"
88
+ f"Legacy metadata:\n```json\n{json.dumps(legacy, indent=2)}\n```"
89
+ )
90
+ )
91
+ ],
92
+ "cedar_template_iri": template_iri,
93
+ },
94
+ config={"recursion_limit": 30},
95
+ )
96
+ )
97
+ print(json.dumps(result["metadata"], indent=2))
98
+ ```
99
+
100
+ The agent answers against a JSON schema built from the template, so the result conforms to the template's field structure. When a model answers without a validated object, a fixed extraction step parses the text into one.
101
+
102
+ ## Caching
103
+
104
+ CEDAR template and BioPortal term responses are cached in SQLite for 24 hours, to keep repeated runs fast and off the rate limits. Override with `ARMS_CACHE_DIR` and `ARMS_CACHE_TTL_SECONDS`.
105
+
106
+ ## Other settings
107
+
108
+ | Variable | Default | What it does |
109
+ | --- | --- | --- |
110
+ | `OPENAI_EXTRACTION_MODEL` | `gpt-4.1-mini` | The model that parses a reply into an object when the main model answers without one. |
111
+ | `OPENAI_COST_MULTIPLIER` | `1.0` | Scales the reported cost when your endpoint charges a fraction of OpenAI's list prices. |
112
+ | `OPENAI_COST_CACHE_DISCOUNT` | `true` | Whether the endpoint discounts cached input tokens. |
113
+
114
+ Costs are local estimates from provider-reported token counts, not billed amounts.
115
+
116
+ ## License
117
+
118
+ BSD 2-Clause.
@@ -0,0 +1,93 @@
1
+ [project]
2
+ name = "arms-agent"
3
+ version = "1.0.0"
4
+ description = "An ontology-constrained LLM agent that standardizes legacy biomedical metadata into CEDAR templates"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "BSD-2-Clause"
8
+ license-files = ["LICENSE"]
9
+ authors = [
10
+ { name = "Josef Hardi" },
11
+ ]
12
+ keywords = ["metadata", "cedar", "ontology", "biomedical", "langgraph", "llm-agent"]
13
+ classifiers = [
14
+ "Development Status :: 5 - Production/Stable",
15
+ "Intended Audience :: Science/Research",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Operating System :: OS Independent",
20
+ "Topic :: Scientific/Engineering :: Bio-Informatics",
21
+ ]
22
+
23
+ dependencies = [
24
+ "langchain>=1.2,<2.0",
25
+ "langgraph>=1.0,<2.0",
26
+ "langchain-openai>=1.0,<2.0",
27
+ "langchain-core>=1.2,<2.0",
28
+ "pydantic>=2.0,<3.0",
29
+ "cedar-mcp>=1.3.0",
30
+ "python-dotenv>=1.2.1",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ # Every Langfuse import is function-local and gated on tracing_enabled(), so the
35
+ # agent runs without it. Only projects that trace pay for the SDK.
36
+ tracing = [
37
+ "langfuse>=4.14,<5.0",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://github.com/musen-lab/metadata-standardization-agent"
42
+ Repository = "https://github.com/musen-lab/metadata-standardization-agent"
43
+ Issues = "https://github.com/musen-lab/metadata-standardization-agent/issues"
44
+ Paper = "https://arxiv.org/abs/2604.08552"
45
+
46
+ [project.scripts]
47
+ arms-migrate = "arms_agent.__main__:main"
48
+
49
+ [build-system]
50
+ requires = ["hatchling"]
51
+ build-backend = "hatchling.build"
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/arms_agent"]
55
+
56
+ [tool.hatch.build.targets.sdist]
57
+ include = ["src/arms_agent", "tests", "README.md", "LICENSE", "pyproject.toml"]
58
+ # Hatchling always ships the nearest VCS ignore file and cannot be told not to,
59
+ # so arms-agent/.gitignore exists to be that file. Without it the workspace's own
60
+ # ignore list is published, naming internal paths no installer needs.
61
+
62
+ # These rules mirror the workspace root, kept inline rather than extended from it.
63
+ # The sdist ships this file without its parent, so an `extend` there resolves to
64
+ # nothing and every ruff run inside an unpacked sdist fails.
65
+ [tool.ruff]
66
+ target-version = "py312"
67
+ line-length = 120
68
+ src = ["src", "tests"]
69
+
70
+ [tool.ruff.lint]
71
+ select = [
72
+ "E", # pycodestyle errors
73
+ "W", # pycodestyle warnings
74
+ "F", # pyflakes
75
+ "I", # isort
76
+ "N", # pep8-naming
77
+ "UP", # pyupgrade
78
+ "B", # flake8-bugbear
79
+ "SIM", # flake8-simplify
80
+ "TCH", # flake8-type-checking
81
+ ]
82
+
83
+ [tool.ruff.lint.isort]
84
+ known-first-party = ["arms_agent"]
85
+
86
+ [tool.ruff.lint.per-file-ignores]
87
+ # The system prompt is authored as Markdown; long lines and intentional
88
+ # trailing-space line breaks are part of the prompt text.
89
+ "src/arms_agent/prompts.py" = ["E501", "W291"]
90
+
91
+ [tool.pytest.ini_options]
92
+ testpaths = ["tests"]
93
+ addopts = "-v --tb=short"
@@ -0,0 +1,13 @@
1
+ """Metadata Standardization Agent: A LangGraph agent for migrating legacy metadata to CEDAR template format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.metadata import PackageNotFoundError, version
6
+
7
+ try:
8
+ # pyproject.toml is the only place the version is written. Reading it back
9
+ # from the installed distribution keeps a second copy from drifting.
10
+ __version__ = version("arms-agent")
11
+ except PackageNotFoundError:
12
+ # A source tree that was never installed still has to import.
13
+ __version__ = "0+unknown"
@@ -0,0 +1,127 @@
1
+ """Entry point for running the metadata standardization agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import logging
9
+ import tempfile
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from dotenv import find_dotenv, load_dotenv
14
+ from langchain_core.messages import HumanMessage
15
+
16
+ from arms_agent.agent import build_migration_agent, build_response_format
17
+ from arms_agent.logging_config import configure_logging
18
+ from arms_agent.prompts import SYSTEM_PROMPT
19
+ from arms_agent.token_tracker import TokenUsageTracker
20
+ from arms_agent.tools import all_tools
21
+ from arms_agent.tracing import flush_tracing, instrument, traced_run
22
+ from arms_agent.workflow import build_workflow
23
+
24
+ # Load environment variables from the nearest .env, searching upward from the working
25
+ # directory. Counting parent directories from this file would only work inside a source
26
+ # checkout; installed from a wheel, the package sits in site-packages and has no project
27
+ # root above it. find_dotenv returns "" when there is no .env, and load_dotenv("") is a
28
+ # no-op, so a run without one is fine.
29
+ load_dotenv(find_dotenv(usecwd=True), override=True)
30
+
31
+ logger = logging.getLogger("arms_agent.__main__")
32
+
33
+
34
+ def main() -> None:
35
+ """Run the migration agent with a legacy metadata record and CEDAR template IRI."""
36
+ parser = argparse.ArgumentParser(
37
+ description="Migrate a legacy metadata record to a CEDAR template format.",
38
+ )
39
+ parser.add_argument("--input", required=True, help="Path to the legacy metadata JSON file.")
40
+ parser.add_argument("--target-schema", required=True, help="IRI of the CEDAR template to migrate to.")
41
+ parser.add_argument(
42
+ "--output",
43
+ help="Output file path or directory. If a directory, the output filename is derived from the input. "
44
+ f"(default: {Path(tempfile.gettempdir()) / 'migrated-metadata.json'})",
45
+ )
46
+ parser.add_argument(
47
+ "--model",
48
+ default="gpt-5.6-terra",
49
+ help="LLM model identifier (default: gpt-5.6-terra).",
50
+ )
51
+ parser.add_argument("--debug", action="store_true", help="Enable debug logging to stderr.")
52
+ args = parser.parse_args()
53
+
54
+ configure_logging(args.debug)
55
+
56
+ logger.debug("Legacy metadata file: %s", args.input)
57
+ logger.debug("CEDAR template IRI: %s", args.target_schema)
58
+
59
+ with open(args.input) as f:
60
+ legacy_metadata = json.load(f)
61
+
62
+ user_message = (
63
+ f"Migrate the following legacy metadata record to the CEDAR template.\n\n"
64
+ f"CEDAR Template IRI: {args.target_schema}\n\n"
65
+ f"Legacy metadata:\n```json\n{json.dumps(legacy_metadata, indent=2)}\n```"
66
+ )
67
+
68
+ agent = build_migration_agent(
69
+ model=args.model,
70
+ system_prompt=SYSTEM_PROMPT,
71
+ response_format=build_response_format(args.target_schema),
72
+ tools=all_tools,
73
+ reasoning_effort="high",
74
+ reasoning_mode="standard",
75
+ )
76
+ workflow = build_workflow(agent)
77
+ tracker = TokenUsageTracker()
78
+ input_stem = Path(args.input).stem
79
+ run_metadata = {
80
+ "input_file": Path(args.input).name,
81
+ "template_iri": args.target_schema,
82
+ "model": args.model,
83
+ }
84
+ run_config = instrument(
85
+ {
86
+ "recursion_limit": 30,
87
+ "callbacks": [tracker],
88
+ "run_name": f"migrate-{input_stem}",
89
+ "tags": ["cli", "migrate"],
90
+ "metadata": run_metadata,
91
+ }
92
+ )
93
+ start = time.perf_counter()
94
+ try:
95
+ with traced_run(f"migrate-{input_stem}", run_metadata):
96
+ result = asyncio.run(
97
+ workflow.ainvoke(
98
+ {
99
+ "messages": [HumanMessage(content=user_message)],
100
+ "cedar_template_iri": args.target_schema,
101
+ },
102
+ config=run_config,
103
+ )
104
+ )
105
+ finally:
106
+ # Traces of a failed run are the ones worth keeping, so flush either way.
107
+ flush_tracing()
108
+ elapsed = time.perf_counter() - start
109
+ if args.output is None:
110
+ output_path = Path(tempfile.gettempdir()) / "migrated-metadata.json"
111
+ elif Path(args.output).is_dir():
112
+ output_path = Path(args.output) / f"{input_stem}.json"
113
+ else:
114
+ output_path = Path(args.output)
115
+ output_path.parent.mkdir(parents=True, exist_ok=True)
116
+ output_path.write_text(json.dumps(result["metadata"], indent=2) + "\n")
117
+ print(f"Output written to: {output_path}")
118
+
119
+ decisions_path = output_path.with_suffix(".decisions.json")
120
+ decisions_path.write_text(json.dumps(result.get("decisions") or [], indent=2) + "\n")
121
+ print(f"Processing log written to: {decisions_path}")
122
+ print(f"Execution time: {elapsed:.2f}s")
123
+ print(tracker.usage_summary())
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()
@@ -0,0 +1,138 @@
1
+ """LangGraph agent for metadata migration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import re
8
+ from typing import TYPE_CHECKING, Any, Literal
9
+
10
+ from langchain.agents import create_agent
11
+ from langchain.agents.structured_output import ProviderStrategy
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Sequence
15
+
16
+ from langchain_core.tools import BaseTool
17
+ from langgraph.graph.state import CompiledStateGraph
18
+
19
+ from arms_agent.schema import build_response_model
20
+ from arms_agent.state import AgentState
21
+ from arms_agent.tools import get_cedar_template
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ # OpenAI o-series reasoning models don't support parallel_tool_calls
26
+ _O_SERIES = re.compile(r"^o\d")
27
+
28
+ # Which models take reasoning settings. Not gpt-5.6 alone: the o-series and the whole
29
+ # gpt-5 family accept them, and a non-reasoning model such as gpt-4.1 rejects the
30
+ # request outright with "'reasoning.effort' is not supported with this model".
31
+ _REASONING_MODELS = re.compile(r"^(o\d|gpt-5)")
32
+
33
+ # The values the API accepts, as it reports them when given anything else.
34
+ ReasoningEffort = Literal["none", "minimal", "low", "medium", "high", "xhigh", "max"]
35
+ ReasoningMode = Literal["standard", "pro"]
36
+
37
+ # Where the OpenAI-compatible API lives. ``OPENAI_BASE_URL`` is the OpenAI SDK's own
38
+ # name for this, so a gateway configured for any other OpenAI client works here
39
+ # unchanged; ``OPENAI_API_BASE`` is accepted as well because langchain reads that one.
40
+ _BASE_URL_VARS = ("OPENAI_BASE_URL", "OPENAI_API_BASE")
41
+
42
+
43
+ def resolve_base_url() -> str | None:
44
+ """Return the API endpoint to call, or ``None`` for OpenAI's own.
45
+
46
+ Set one of the variables in :data:`_BASE_URL_VARS` to route every call through a
47
+ gateway, such as the Stanford API Gateway, instead of ``api.openai.com``. The key
48
+ in ``OPENAI_API_KEY`` then has to be the one that gateway issues.
49
+ """
50
+ for name in _BASE_URL_VARS:
51
+ value = os.environ.get(name, "").strip()
52
+ if value:
53
+ return value
54
+ return None
55
+
56
+
57
+ def _reasoning_kwargs(
58
+ model: str,
59
+ reasoning_effort: ReasoningEffort,
60
+ reasoning_mode: ReasoningMode,
61
+ ) -> dict[str, Any]:
62
+ """Return the reasoning settings to hand ``ChatOpenAI``.
63
+
64
+ A model that does not reason gets nothing, since it rejects the request outright.
65
+ """
66
+ if not _REASONING_MODELS.match(model):
67
+ return {}
68
+ return {"reasoning": {"effort": reasoning_effort, "mode": reasoning_mode}}
69
+
70
+
71
+ def build_response_format(template_iri: str) -> ProviderStrategy:
72
+ """Return the provider-enforced response format for *template_iri*.
73
+
74
+ Raises:
75
+ ValueError: If the template cannot be fetched, which would otherwise bind an
76
+ empty schema and silently produce empty records for a whole run.
77
+ """
78
+ template_dict = get_cedar_template.invoke({"template_id": template_iri})
79
+ if "error" in template_dict or not template_dict.get("children"):
80
+ msg = f"Cannot build a response schema for {template_iri}: {template_dict.get('error', 'no fields returned')}"
81
+ raise ValueError(msg)
82
+ return ProviderStrategy(build_response_model(template_dict), strict=True)
83
+
84
+
85
+ def build_migration_agent(
86
+ model: str,
87
+ system_prompt: str,
88
+ response_format: ProviderStrategy | None,
89
+ tools: Sequence[BaseTool],
90
+ reasoning_effort: ReasoningEffort = "low",
91
+ reasoning_mode: ReasoningMode = "standard",
92
+ ) -> CompiledStateGraph:
93
+ """Build the agent that performs the migration.
94
+
95
+ Args:
96
+ model: The OpenAI model identifier to use.
97
+ system_prompt: The system prompt carrying the agent's policy.
98
+ response_format: The schema the final answer is bound to.
99
+ ``None`` leaves the answer unconstrained, as free text the extraction node then has to parse.
100
+ tools: The tools this agent may call.
101
+ reasoning_effort: How much reasoning the model spends before answering.
102
+ Ignored by models that do not reason.
103
+ reasoning_mode: Which reasoning behaviour to use. Ignored by models that
104
+ do not reason.
105
+
106
+ Returns:
107
+ A compiled agent graph.
108
+ """
109
+ from langchain_openai import ChatOpenAI
110
+
111
+ base_url = resolve_base_url()
112
+ reasoning_kwargs = _reasoning_kwargs(model, reasoning_effort, reasoning_mode)
113
+
114
+ logger.info(
115
+ "Building migration agent with model=%s, tools=%d, structured_output=%s, reasoning=%s, endpoint=%s",
116
+ model,
117
+ len(tools),
118
+ response_format is not None,
119
+ reasoning_kwargs or None,
120
+ base_url or "api.openai.com",
121
+ )
122
+ model_kwargs: dict[str, Any] = {}
123
+ if tools and not _O_SERIES.match(model):
124
+ model_kwargs["parallel_tool_calls"] = True
125
+ llm = ChatOpenAI(
126
+ base_url=base_url,
127
+ model=model,
128
+ temperature=0,
129
+ model_kwargs=model_kwargs,
130
+ **reasoning_kwargs,
131
+ )
132
+ return create_agent(
133
+ llm,
134
+ tools=tools,
135
+ system_prompt=system_prompt,
136
+ state_schema=AgentState,
137
+ response_format=response_format,
138
+ )