Coverage for core/langgraph/tools/resolver.py: 100%

60 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-10-05 00:44 +0000

1"""Resolution-composition layer across the three tool sources. 

2 

3An agent declares `tools: [<name>]`; this module turns that declaration into 

4ordered `Tool` instances by consulting the single Core-owned `ToolRegistry` 

5(file-access tools ∪ platform builtins) first and the StepFunction register 

6second (ADR-0039 D2). A name present in both sources is a hard collision, never 

7a silent precedence choice (ADR-0041 D1). 

8""" 

9from __future__ import annotations 

10 

11import json 

12from collections.abc import Sequence 

13from typing import Any 

14 

15from .contract import Tool, ToolContext, ToolResult 

16from .registry import ToolRegistry, ToolRegistryError 

17 

18_TOOL_MODULES: dict[str, str] = { 

19 "read_file": "langgraph.tools.read_file", 

20 "list_dir": "langgraph.tools.list_dir", 

21 "list-dir-entries": "langgraph.tools.list_dir_entries", 

22 "aggregate": "langgraph.tools.aggregate", 

23 "ticket-publisher": "langgraph.tools.ticket_publisher", 

24 "run-finalizer": "langgraph.tools.run_finalizer", 

25 "git-checkout": "langgraph.tools.git_checkout", 

26 "files-write": "langgraph.tools.files_write", 

27 "files-delete": "langgraph.tools.files_delete", 

28 "question-block-form": "langgraph.tools.question_block_form", 

29 "github-pr-comment": "langgraph.tools.github_pr_comment", 

30 "ticket-publish": "langgraph.tools.ticket_publish", 

31 "ticket-context": "langgraph.tools.ticket_context", 

32 "ticket-lane": "langgraph.tools.ticket_lane", 

33 "ticket-dor": "langgraph.tools.ticket_dor", 

34 "ticket-review": "langgraph.tools.ticket_review", 

35 "ticket-search": "langgraph.tools.ticket_search", 

36 "ticket-create": "langgraph.tools.ticket_create", 

37 "ticket-approval": "langgraph.tools.ticket_approval", 

38 "submit_decision": "langgraph.tools.submit_decision", 

39} 

40 

41 

42class ToolCollisionError(ToolRegistryError): 

43 """A declared name is registered in both platform and StepFunction sources.""" 

44 

45 code = "BUILTIN_NAME_COLLISION" 

46 

47 def __init__(self, name: str) -> None: 

48 self.name = name 

49 super().__init__( 

50 f"tool {name!r} exists in both the platform registry and the " 

51 f"StepFunction register" 

52 ) 

53 

54 

55def build_platform_registry() -> ToolRegistry: 

56 """A fresh registry holding every platform tool as a lazy locator.""" 

57 registry = ToolRegistry() 

58 for name, module_path in _TOOL_MODULES.items(): 

59 registry.register_lazy(name, module_path, attr="TOOL") 

60 return registry 

61 

62 

63class ToolResolver: 

64 """Composes the registry and the StepFunction register into one surface.""" 

65 

66 def __init__( 

67 self, registry: ToolRegistry, step_functions: object | None = None 

68 ) -> None: 

69 self._registry = registry 

70 self._step_functions = step_functions 

71 

72 def resolve(self, names: Sequence[str]) -> list[Tool]: 

73 """Resolve *names* in declaration order; duplicates are preserved.""" 

74 return [self._resolve_one(name) for name in names] 

75 

76 def _resolve_one(self, name: str) -> Tool: 

77 if self._is_collision(name): 

78 raise ToolCollisionError(name) 

79 if self._registry.has(name): 

80 return self._registry.resolve([name])[0] 

81 if self._has_step_function(name): 

82 return _StepFunctionTool(name, self._step_functions) 

83 raise ToolRegistryError(f"tool {name!r} is not registered") 

84 

85 def _is_collision(self, name: str) -> bool: 

86 return self._registry.has(name) and self._has_step_function(name) 

87 

88 def _has_step_function(self, name: str) -> bool: 

89 return self._step_functions is not None and self._step_functions.has(name) 

90 

91 

92def _hard_failure(code: str, message: str, details: dict | None = None) -> Any: 

93 """Build the engine's RunHardFailure without a module-level circular import.""" 

94 try: 

95 from runner import RunHardFailure 

96 except ImportError: # pragma: no cover — main.py qualified imports 

97 from langgraph.runner import RunHardFailure 

98 return RunHardFailure(code, message, details) 

99 

100 

101class _StepFunctionTool: 

102 """Adapts an injected StepFunction executor to the `Tool` protocol.""" 

103 

104 def __init__(self, name: str, step_functions: object) -> None: 

105 self.name = name 

106 self._step_functions = step_functions 

107 

108 def instruction(self) -> str | None: 

109 return self._step_functions.instruction_for(self.name) 

110 

111 def params(self) -> list[dict[str, Any]] | None: 

112 return self._step_functions.params_for(self.name) 

113 

114 def definition(self) -> dict[str, Any]: 

115 return { 

116 "type": "function", 

117 "function": { 

118 "name": self.name, 

119 "description": self.instruction() or "", 

120 "parameters": _params_schema(self.params()), 

121 }, 

122 } 

123 

124 def execute(self, context: ToolContext) -> ToolResult: 

125 step_id = (context.step or {}).get("id", self.name) 

126 step = {"id": step_id, "params": dict(context.args or {})} 

127 state = self._step_functions.execute(self.name, step, context.state or {}) 

128 fragment = state.get(step_id) 

129 if fragment is None: 

130 raise _hard_failure( 

131 "UNRESOLVED_OUTPUT", 

132 f"StepFunction '{self.name}': executor returned no fragment for " 

133 f"step '{step_id}'", 

134 {"step_function": self.name, "step_id": step_id}, 

135 ) 

136 return ToolResult(ok=True, content=json.dumps(fragment), state_patch=None) 

137 

138 

139def _params_schema(params: list[dict[str, Any]] | None) -> dict[str, Any]: 

140 """Emit an untyped JSON schema for a StepFunction's declared params. 

141 

142 Every property is emitted as `{}` because the manifest descriptor 

143 (`StepFunctionInterfaceSpec.params` in `manifest.schema.json`) carries only 

144 `name`, `required`, and `default` — no type descriptor (its items are 

145 closed by `additionalProperties: false`). The absence of types, and the 

146 non-propagation of `default` into the schema, are a deliberate response to 

147 that manifest limitation, not an oversight: inferring a type from `default` 

148 would be a silent heuristic, forbidden by ADR-0012. 

149 """ 

150 declared = params or [] 

151 return { 

152 "type": "object", 

153 "properties": {param["name"]: {} for param in declared}, 

154 "required": [param["name"] for param in declared if param.get("required")], 

155 "additionalProperties": False, 

156 }