inquirycraft 0.6.0__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.
Files changed (102) hide show
  1. inquirycraft/__init__.py +76 -0
  2. inquirycraft/__main__.py +4 -0
  3. inquirycraft/adapters.py +127 -0
  4. inquirycraft/agent/__init__.py +5 -0
  5. inquirycraft/agent/base.py +194 -0
  6. inquirycraft/cli/__init__.py +13 -0
  7. inquirycraft/cli/app.py +231 -0
  8. inquirycraft/cli/contracts.py +46 -0
  9. inquirycraft/cli/factory.py +33 -0
  10. inquirycraft/events/__init__.py +25 -0
  11. inquirycraft/events/models.py +70 -0
  12. inquirycraft/events/replay.py +307 -0
  13. inquirycraft/events/sink.py +231 -0
  14. inquirycraft/executor/__init__.py +15 -0
  15. inquirycraft/executor/base.py +88 -0
  16. inquirycraft/executor/dep_utils.py +40 -0
  17. inquirycraft/executor/output.py +167 -0
  18. inquirycraft/executor/python.py +129 -0
  19. inquirycraft/executor/shell.py +65 -0
  20. inquirycraft/llm/__init__.py +42 -0
  21. inquirycraft/llm/base.py +288 -0
  22. inquirycraft/llm/errors.py +16 -0
  23. inquirycraft/llm/factory.py +95 -0
  24. inquirycraft/llm/logprob_stream.py +160 -0
  25. inquirycraft/llm/message_format.py +81 -0
  26. inquirycraft/llm/models.py +42 -0
  27. inquirycraft/llm/online.py +71 -0
  28. inquirycraft/llm/online_support.py +99 -0
  29. inquirycraft/llm/openai_adapter.py +109 -0
  30. inquirycraft/llm/pool.py +142 -0
  31. inquirycraft/llm/pooled.py +344 -0
  32. inquirycraft/llm/protocol.py +15 -0
  33. inquirycraft/llm/public_requests.py +123 -0
  34. inquirycraft/llm/request_client.py +230 -0
  35. inquirycraft/llm/retry.py +50 -0
  36. inquirycraft/llm/stream_types.py +123 -0
  37. inquirycraft/llm/text_stream.py +154 -0
  38. inquirycraft/llm/tool_args.py +134 -0
  39. inquirycraft/llm/tool_requests.py +194 -0
  40. inquirycraft/llm/tool_stream_result.py +90 -0
  41. inquirycraft/llm/tool_stream_state.py +187 -0
  42. inquirycraft/llm/usage.py +55 -0
  43. inquirycraft/llm/utils.py +288 -0
  44. inquirycraft/mcp/__init__.py +8 -0
  45. inquirycraft/mcp/client.py +118 -0
  46. inquirycraft/memory/__init__.py +83 -0
  47. inquirycraft/memory/context.py +63 -0
  48. inquirycraft/memory/conversation.py +83 -0
  49. inquirycraft/memory/history.py +184 -0
  50. inquirycraft/memory/kv_storage.py +69 -0
  51. inquirycraft/memory/legacy_records.py +27 -0
  52. inquirycraft/memory/message.py +260 -0
  53. inquirycraft/memory/mutation.py +67 -0
  54. inquirycraft/memory/paths.py +19 -0
  55. inquirycraft/memory/record_store.py +218 -0
  56. inquirycraft/memory/records.py +64 -0
  57. inquirycraft/runtime/__init__.py +42 -0
  58. inquirycraft/runtime/cancellation.py +24 -0
  59. inquirycraft/runtime/engine.py +313 -0
  60. inquirycraft/runtime/hooks.py +97 -0
  61. inquirycraft/runtime/hosted.py +114 -0
  62. inquirycraft/runtime/resume.py +172 -0
  63. inquirycraft/runtime/state.py +15 -0
  64. inquirycraft/runtime/streaming.py +95 -0
  65. inquirycraft/runtime/subprocess.py +298 -0
  66. inquirycraft/runtime/subprocess_sync.py +122 -0
  67. inquirycraft/runtime/telemetry.py +87 -0
  68. inquirycraft/tools/__init__.py +136 -0
  69. inquirycraft/tools/artifacts/__init__.py +11 -0
  70. inquirycraft/tools/artifacts/reducers.py +41 -0
  71. inquirycraft/tools/artifacts/store.py +146 -0
  72. inquirycraft/tools/base.py +188 -0
  73. inquirycraft/tools/builtin.py +101 -0
  74. inquirycraft/tools/collection.py +100 -0
  75. inquirycraft/tools/dispatch.py +75 -0
  76. inquirycraft/tools/execution/__init__.py +7 -0
  77. inquirycraft/tools/execution/engine.py +105 -0
  78. inquirycraft/tools/execution/middleware.py +30 -0
  79. inquirycraft/tools/execution/models.py +19 -0
  80. inquirycraft/tools/executor.py +54 -0
  81. inquirycraft/tools/file_tools.py +269 -0
  82. inquirycraft/tools/filesystem/__init__.py +65 -0
  83. inquirycraft/tools/filesystem/edit.py +263 -0
  84. inquirycraft/tools/filesystem/modify.py +168 -0
  85. inquirycraft/tools/filesystem/observe.py +323 -0
  86. inquirycraft/tools/filesystem/search.py +205 -0
  87. inquirycraft/tools/guards.py +160 -0
  88. inquirycraft/tools/path_policy.py +277 -0
  89. inquirycraft/tools/search_tools.py +190 -0
  90. inquirycraft/tools/shell_feedback.py +65 -0
  91. inquirycraft/tools/shell_guards.py +51 -0
  92. inquirycraft/tools/shell_output.py +109 -0
  93. inquirycraft/tools/shell_reducer.py +198 -0
  94. inquirycraft/tools/shell_traceback.py +220 -0
  95. inquirycraft/tools/tool_calls.py +51 -0
  96. inquirycraft/tools/write_safety.py +90 -0
  97. inquirycraft-0.6.0.dist-info/METADATA +222 -0
  98. inquirycraft-0.6.0.dist-info/RECORD +102 -0
  99. inquirycraft-0.6.0.dist-info/WHEEL +5 -0
  100. inquirycraft-0.6.0.dist-info/entry_points.txt +2 -0
  101. inquirycraft-0.6.0.dist-info/licenses/LICENSE +9 -0
  102. inquirycraft-0.6.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,76 @@
1
+ """InquiryCraft's lightweight, domain-independent agent runtime.
2
+
3
+ Every public symbol is resolved lazily. Importing the package does not initialize
4
+ the runtime, CLI, or provider adapters.
5
+ """
6
+
7
+ from importlib import import_module
8
+ from importlib.metadata import PackageNotFoundError, version
9
+ from typing import Any
10
+
11
+ try:
12
+ __version__ = version("inquirycraft")
13
+ except PackageNotFoundError: # source checkout without an installed distribution
14
+ __version__ = "0+unknown"
15
+
16
+ _EXPORTS = {
17
+ "AgentHooks": ("inquirycraft.runtime", "AgentHooks"),
18
+ "AgentRuntime": ("inquirycraft.runtime", "AgentRuntime"),
19
+ "BaseTool": ("inquirycraft.tools", "BaseTool"),
20
+ "CompletionRequest": ("inquirycraft.llm", "CompletionRequest"),
21
+ "CompletionResult": ("inquirycraft.llm", "CompletionResult"),
22
+ "CompositeEventSink": ("inquirycraft.events", "CompositeEventSink"),
23
+ "Conversation": ("inquirycraft.memory", "Conversation"),
24
+ "EventSink": ("inquirycraft.events", "EventSink"),
25
+ "JsonlEventSink": ("inquirycraft.events", "JsonlEventSink"),
26
+ "LLMClient": ("inquirycraft.llm", "LLMClient"),
27
+ "Message": ("inquirycraft.memory", "Message"),
28
+ "RecordingLLMClient": ("inquirycraft.events", "RecordingLLMClient"),
29
+ "ReplayLLMClient": ("inquirycraft.events", "ReplayLLMClient"),
30
+ "ResumeState": ("inquirycraft.runtime", "ResumeState"),
31
+ "RuntimeContext": ("inquirycraft.runtime", "RuntimeContext"),
32
+ "RuntimeEvent": ("inquirycraft.events", "RuntimeEvent"),
33
+ "RuntimeOptions": ("inquirycraft.runtime", "RuntimeOptions"),
34
+ "ToolCollection": ("inquirycraft.tools", "ToolCollection"),
35
+ "ToolContext": ("inquirycraft.tools", "ToolContext"),
36
+ "ToolResult": ("inquirycraft.tools", "ToolResult"),
37
+ "Usage": ("inquirycraft.llm", "Usage"),
38
+ }
39
+
40
+ __all__ = [
41
+ "__version__",
42
+ "AgentHooks",
43
+ "AgentRuntime",
44
+ "BaseTool",
45
+ "CompletionRequest",
46
+ "CompletionResult",
47
+ "CompositeEventSink",
48
+ "Conversation",
49
+ "EventSink",
50
+ "JsonlEventSink",
51
+ "LLMClient",
52
+ "Message",
53
+ "RecordingLLMClient",
54
+ "ReplayLLMClient",
55
+ "ResumeState",
56
+ "RuntimeContext",
57
+ "RuntimeEvent",
58
+ "RuntimeOptions",
59
+ "ToolCollection",
60
+ "ToolContext",
61
+ "ToolResult",
62
+ "Usage",
63
+ ]
64
+
65
+
66
+ def __getattr__(name: str) -> Any:
67
+ if name not in _EXPORTS:
68
+ raise AttributeError(name)
69
+ module_name, attribute = _EXPORTS[name]
70
+ value = getattr(import_module(module_name), attribute)
71
+ globals()[name] = value
72
+ return value
73
+
74
+
75
+ def __dir__() -> list[str]:
76
+ return sorted(set(globals()) | set(__all__))
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,127 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import AsyncIterator, Mapping
4
+ from typing import Any
5
+
6
+ from inquirycraft.llm import CompletionRequest, CompletionResult, StreamChunk, Usage
7
+ from inquirycraft.memory import Message
8
+ from inquirycraft.tools import BaseTool, ToolContext, ToolResult
9
+
10
+
11
+ def legacy_message_to_message(value: Any) -> Message:
12
+ """Convert a legacy Message or OpenAI-style dict without importing its package."""
13
+ if isinstance(value, Mapping):
14
+ row = dict(value)
15
+ elif hasattr(value, "to_dict"):
16
+ row = value.to_dict()
17
+ elif hasattr(value, "model_dump"):
18
+ row = value.model_dump(exclude_none=True)
19
+ else:
20
+ raise TypeError(f"Unsupported legacy message: {type(value)!r}")
21
+ return Message.from_dict(row)
22
+
23
+
24
+ class LegacyAskLLMAdapter:
25
+ """Expose an existing ask()/ask_tool()-compatible client as ``LLMClient``.
26
+
27
+ This adapter lets hosts adopt the runtime contract while retaining an existing
28
+ provider implementation, retry policy, and request parameters.
29
+ """
30
+
31
+ def __init__(self, legacy_llm: Any) -> None:
32
+ self.legacy_llm = legacy_llm
33
+
34
+ @staticmethod
35
+ def _usage(legacy_llm: Any) -> Usage:
36
+ return Usage(
37
+ input_tokens=int(getattr(legacy_llm, "_last_call_input_tokens", 0) or 0),
38
+ output_tokens=int(getattr(legacy_llm, "_last_call_output_tokens", 0) or 0),
39
+ cached_input_tokens=int(getattr(legacy_llm, "_last_call_input_cached_tokens", 0) or 0),
40
+ )
41
+
42
+ async def complete(self, request: CompletionRequest) -> CompletionResult:
43
+ messages = [dict(message) for message in request.messages]
44
+ if request.tools:
45
+ response = await self.legacy_llm.ask_tool(
46
+ messages=messages,
47
+ system_msgs=None,
48
+ timeout=int(request.timeout or 300),
49
+ tools=[dict(tool) for tool in request.tools],
50
+ tool_choice=request.tool_choice,
51
+ temperature=request.temperature,
52
+ )
53
+ raw_calls = getattr(response, "tool_calls", None) or ()
54
+ tool_calls = tuple(
55
+ call.model_dump(exclude_none=True) if hasattr(call, "model_dump") else dict(call)
56
+ for call in raw_calls
57
+ )
58
+ return CompletionResult(
59
+ content=str(getattr(response, "content", "") or ""),
60
+ reasoning_content=str(getattr(response, "reasoning_content", "") or ""),
61
+ tool_calls=tool_calls,
62
+ finish_reason=str(getattr(self.legacy_llm, "_last_finish_reason", "unknown")),
63
+ usage=self._usage(self.legacy_llm),
64
+ raw=response,
65
+ )
66
+
67
+ content = await self.legacy_llm.ask(
68
+ messages=messages,
69
+ system_msgs=None,
70
+ stream=False,
71
+ temperature=request.temperature,
72
+ timeout=request.timeout,
73
+ )
74
+ return CompletionResult(
75
+ content=str(content or ""),
76
+ finish_reason=str(getattr(self.legacy_llm, "_last_finish_reason", "unknown")),
77
+ usage=self._usage(self.legacy_llm),
78
+ raw=content,
79
+ )
80
+
81
+ async def stream(self, request: CompletionRequest) -> AsyncIterator[StreamChunk]:
82
+ result = await self.complete(request)
83
+ yield StreamChunk(
84
+ content=result.content,
85
+ reasoning_content=result.reasoning_content,
86
+ finish_reason=result.finish_reason,
87
+ usage=result.usage,
88
+ )
89
+
90
+ async def aclose(self) -> None:
91
+ instances = getattr(self.legacy_llm, "_instances", None)
92
+ targets = instances if isinstance(instances, list) else [self.legacy_llm]
93
+ for target in targets:
94
+ client = getattr(target, "client", None)
95
+ close = getattr(client, "close", None)
96
+ if close is not None:
97
+ result = close()
98
+ if hasattr(result, "__await__"):
99
+ await result
100
+
101
+
102
+ class LegacyToolAdapter(BaseTool):
103
+ """Expose a legacy BaseTool through the context-aware contract."""
104
+
105
+ def __init__(self, legacy_tool: Any) -> None:
106
+ self.legacy_tool = legacy_tool
107
+ self.name = str(legacy_tool.name)
108
+ self.description = str(legacy_tool.description)
109
+ self.input_schema = dict(getattr(legacy_tool, "parameters", None) or {})
110
+
111
+ async def execute(self, arguments: Mapping[str, Any], context: ToolContext) -> ToolResult:
112
+ context.cancellation.raise_if_cancelled()
113
+ raw = await self.legacy_tool.execute(**dict(arguments))
114
+ if hasattr(raw, "output") or hasattr(raw, "error"):
115
+ output = getattr(raw, "output", "")
116
+ error = getattr(raw, "error", None)
117
+ system = getattr(raw, "system", None)
118
+ metadata = {"legacy_system": system} if system is not None else {}
119
+ return ToolResult(
120
+ output=str(output or ""),
121
+ error=str(error) if error else None,
122
+ metadata=metadata,
123
+ )
124
+ return ToolResult(output=str(raw or ""))
125
+
126
+
127
+ __all__ = ["LegacyAskLLMAdapter", "LegacyToolAdapter", "legacy_message_to_message"]
@@ -0,0 +1,5 @@
1
+ """Canonical agent model exports."""
2
+
3
+ from .base import BaseAgent, MemoryRetrievalCompatibilityMixin
4
+
5
+ __all__ = ["BaseAgent", "MemoryRetrievalCompatibilityMixin"]
@@ -0,0 +1,194 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from abc import ABC
7
+ from typing import Any
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
10
+
11
+ from inquirycraft.memory import Memory
12
+ from inquirycraft.memory.message import ROLE_TYPE, Message, Role
13
+ from inquirycraft.runtime import AgentState
14
+ from inquirycraft.tools import (
15
+ TOOL_CHOICE_TYPE,
16
+ ToolCall,
17
+ ToolChoice,
18
+ ToolCollection,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class MemoryRetrievalCompatibilityMixin(ABC):
25
+ """Stable fields for the removed vector-memory integration."""
26
+
27
+ vector_db_path: str = Field(default="./vector_db", description="Vector database path")
28
+ model_name: str = Field(
29
+ default="./intfloat/multilingual-e5-large-instruct",
30
+ description="Encoding model path",
31
+ )
32
+ use_memory_retrieval: bool = Field(default=False)
33
+ retrieval_top_k: int = Field(default=3)
34
+ min_common_length: int = Field(default=100)
35
+ save_to_vector_db: bool = Field(default=False)
36
+ fusion_retrieval: bool = Field(default=False)
37
+ collection_name: str = Field(default="conversations")
38
+
39
+ def _initialize_retrieval_components(self) -> None:
40
+ if self.use_memory_retrieval or self.save_to_vector_db:
41
+ raise RuntimeError("InquiryCraft vector memory retrieval has been removed")
42
+
43
+ async def _retrieve_similar_memories(
44
+ self,
45
+ query: str,
46
+ retrieval_filter: dict[str, Any] | None = None,
47
+ ) -> list[dict[str, Any]]:
48
+ _ = query, retrieval_filter
49
+ self._initialize_retrieval_components()
50
+ return []
51
+
52
+ async def _save_to_vector_db(
53
+ self,
54
+ text: str,
55
+ metadata: dict[str, Any],
56
+ doc_id: str | None = None,
57
+ ) -> None:
58
+ _ = text, metadata, doc_id
59
+ self._initialize_retrieval_components()
60
+
61
+ def get_vector_db_stats(self) -> dict[str, Any]:
62
+ return {"enabled": False, "removed": True}
63
+
64
+
65
+ class BaseAgent(BaseModel, MemoryRetrievalCompatibilityMixin, ABC):
66
+ """Compatibility model for hosts adopting :class:`AgentRuntime` incrementally.
67
+
68
+ It preserves the established Pydantic fields and one-step behavior. New agents
69
+ should use :class:`inquirycraft.runtime.AgentRuntime` directly.
70
+ """
71
+
72
+ name: str | None = Field(None, description="Unique name of the agent")
73
+ description: str | None = Field(None, description="Optional agent description")
74
+ systemPrompt: str | list[dict] | None = Field(
75
+ None,
76
+ description="System-level instruction prompt",
77
+ )
78
+ llm: Any | None = Field(None, description="Language model instance")
79
+ memory: Memory = Field(default_factory=Memory, description="Agent's memory store")
80
+ state: AgentState = Field(default=AgentState.IDLE, description="Current agent state")
81
+ availableTools: ToolCollection | None = Field(None, description="List of available tools")
82
+ toolChoices: TOOL_CHOICE_TYPE | None = Field(
83
+ ToolChoice.AUTO,
84
+ description="Tool choice strategy",
85
+ )
86
+ toolCalls: list[ToolCall] | None = Field(None, description="List of tool calls")
87
+ ToolConfig: dict | None = Field(None, description="Other tool configuration")
88
+ exec_mode: str | None = Field(None, description="execution mode:single turn or multiturn")
89
+
90
+ model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow")
91
+
92
+ @model_validator(mode="after")
93
+ def initializeAgent(self) -> "BaseAgent":
94
+ if self.llm is None:
95
+ raise ValueError("llm is none!")
96
+ if not isinstance(self.memory, Memory):
97
+ self.memory = Memory()
98
+ return self
99
+
100
+ async def run(self, request: str | list[dict] | None = None) -> str:
101
+ if self.state != AgentState.IDLE:
102
+ raise RuntimeError(f"Cannot run agent from state: {self.state}")
103
+ if request:
104
+ self.updateMemory(Role.USER, request)
105
+ result = await self.step()
106
+ if self.llm:
107
+ self.llm.chunk_queue = asyncio.Queue()
108
+ return result
109
+
110
+ async def step(self) -> str:
111
+ response = await self.llm.ask(
112
+ messages=self.memory.messages,
113
+ system_msgs=[Message.system_message(self.systemPrompt)],
114
+ )
115
+ self.updateMemory(Role.ASSISTANT, response)
116
+ return response
117
+
118
+ def updateMemory(
119
+ self,
120
+ role: ROLE_TYPE,
121
+ content: str | Message,
122
+ extra_info: dict[str, Any] | None = None,
123
+ **kwargs: Any,
124
+ ) -> None:
125
+ if isinstance(content, Message):
126
+ message = content
127
+ else:
128
+ factories = {
129
+ Role.USER: Message.user_message,
130
+ Role.SYSTEM: Message.system_message,
131
+ Role.ASSISTANT: Message.assistant_message,
132
+ Role.TOOL: lambda value, **kw: Message.tool_message(value, **kw),
133
+ }
134
+ if role not in factories:
135
+ raise ValueError(f"Unsupported message role: {role}")
136
+ factory = factories[role]
137
+ message = factory(content, **kwargs) if role == Role.TOOL else factory(content)
138
+ if extra_info:
139
+ self.memory.add_message(message, extra_info=extra_info)
140
+ else:
141
+ self.memory.add_message(message)
142
+
143
+ async def executeTool(self, command: ToolCall) -> str:
144
+ if not command or not command.function or not command.function.name:
145
+ return "Error: Invalid command format"
146
+ name = command.function.name
147
+ if self.availableTools is None or name not in self.availableTools.tool_map:
148
+ return f"Error: Unknown tool '{name}'"
149
+ try:
150
+ arguments = json.loads(command.function.arguments or "{}")
151
+ arguments["config"] = self.ToolConfig
152
+ logger.info("Activating tool: %r", name)
153
+ result = await self.availableTools.execute(name=name, tool_input=arguments)
154
+ observation = (
155
+ f"Observed output of cmd `{name}` executed:\n{result}"
156
+ if result
157
+ else f"Cmd `{name}` completed with no output"
158
+ )
159
+ await self._handleSpecialTool(name=name, result=result)
160
+ return observation
161
+ except json.JSONDecodeError:
162
+ logger.error("Invalid JSON arguments for %r: %s", name, command.function.arguments)
163
+ return f"Error: Error parsing arguments for {name}: Invalid JSON format"
164
+ except Exception as exc:
165
+ error = f"Tool '{name}' encountered a problem: {exc}"
166
+ logger.error(error)
167
+ return f"Error: {error}"
168
+
169
+ async def _handleSpecialTool(self, name: str, result: Any, **kwargs: Any) -> None:
170
+ if self._isSpecialTool(name) and self._shouldFinishExecution(
171
+ name=name,
172
+ result=result,
173
+ **kwargs,
174
+ ):
175
+ self.state = AgentState.FINISHED
176
+
177
+ @staticmethod
178
+ def _shouldFinishExecution(**kwargs: Any) -> bool:
179
+ return True
180
+
181
+ def _isSpecialTool(self, name: str) -> bool:
182
+ names = getattr(self, "special_tool_names", ())
183
+ return name.lower() in [value.lower() for value in names]
184
+
185
+ @property
186
+ def messages(self) -> list[Message]:
187
+ return self.memory.messages
188
+
189
+ @messages.setter
190
+ def messages(self, value: list[Message]) -> None:
191
+ self.memory.messages = value
192
+
193
+
194
+ __all__ = ["BaseAgent", "MemoryRetrievalCompatibilityMixin"]
@@ -0,0 +1,13 @@
1
+ from .app import create_cli, main
2
+ from .contracts import CliExtension, CommandContext, CommandRegistry, RuntimeFactory
3
+ from .factory import DefaultRuntimeFactory
4
+
5
+ __all__ = [
6
+ "CliExtension",
7
+ "CommandContext",
8
+ "CommandRegistry",
9
+ "DefaultRuntimeFactory",
10
+ "RuntimeFactory",
11
+ "create_cli",
12
+ "main",
13
+ ]
@@ -0,0 +1,231 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import os
6
+ from collections.abc import Callable, Iterable, Sequence
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import click
11
+
12
+ from inquirycraft.events import JsonlEventSink, NullEventSink, load_events
13
+ from inquirycraft.runtime import RuntimeOptions
14
+ from inquirycraft.tools import default_tools
15
+
16
+ from .contracts import CliExtension, CommandRegistry, RuntimeFactory
17
+ from .factory import DefaultRuntimeFactory
18
+
19
+ RuntimeOptionsDecorator = Callable[[Callable[..., Any]], Callable[..., Any]]
20
+
21
+
22
+ def _runtime_options(
23
+ *,
24
+ model_envvar: str | Sequence[str],
25
+ base_url_envvar: str | Sequence[str],
26
+ api_key_env_default: str,
27
+ ) -> RuntimeOptionsDecorator:
28
+ def decorate(function):
29
+ options = [
30
+ click.option("--model", envvar=model_envvar, required=True, help="Model name."),
31
+ click.option("--base-url", envvar=base_url_envvar, default=None),
32
+ click.option(
33
+ "--api-key-env",
34
+ default=api_key_env_default,
35
+ show_default=True,
36
+ help="Environment variable containing the API key.",
37
+ ),
38
+ click.option(
39
+ "--workspace",
40
+ type=click.Path(path_type=Path, file_okay=False),
41
+ default=Path("."),
42
+ show_default=True,
43
+ ),
44
+ click.option("--system-prompt", default="You are a helpful agent.", show_default=True),
45
+ click.option("--max-turns", type=click.IntRange(1), default=20, show_default=True),
46
+ click.option("--max-tokens", type=click.IntRange(1), default=None),
47
+ click.option(
48
+ "--timeout",
49
+ type=click.FloatRange(min=0.1),
50
+ default=300.0,
51
+ show_default=True,
52
+ ),
53
+ click.option(
54
+ "--event-log",
55
+ type=click.Path(path_type=Path, dir_okay=False),
56
+ default=None,
57
+ ),
58
+ click.option(
59
+ "--session-log",
60
+ type=click.Path(path_type=Path, dir_okay=False),
61
+ default=None,
62
+ help="Append conversation records to JSONL; existing records are resumed.",
63
+ ),
64
+ ]
65
+ for option in reversed(options):
66
+ function = option(function)
67
+ return function
68
+
69
+ return decorate
70
+
71
+
72
+ def _build_runtime(
73
+ factory: RuntimeFactory,
74
+ *,
75
+ model: str,
76
+ base_url: str | None,
77
+ api_key_env: str,
78
+ workspace: Path,
79
+ system_prompt: str,
80
+ max_turns: int,
81
+ max_tokens: int | None,
82
+ timeout: float,
83
+ event_log: Path | None,
84
+ session_log: Path | None,
85
+ ):
86
+ api_key = os.environ.get(api_key_env, "").strip()
87
+ if not api_key:
88
+ raise click.ClickException(f"API key environment variable {api_key_env!r} is empty")
89
+ sink = JsonlEventSink(event_log) if event_log else NullEventSink()
90
+ options = RuntimeOptions(
91
+ model=model,
92
+ workspace=workspace,
93
+ system_prompt=system_prompt,
94
+ max_turns=max_turns,
95
+ max_tokens=max_tokens,
96
+ timeout=timeout,
97
+ session_log=session_log,
98
+ )
99
+ return factory.create_runtime(
100
+ options,
101
+ api_key=api_key,
102
+ base_url=base_url,
103
+ event_sink=sink,
104
+ )
105
+
106
+
107
+ def _register_run_command(
108
+ app: click.Group,
109
+ factory: RuntimeFactory,
110
+ options: RuntimeOptionsDecorator,
111
+ ) -> None:
112
+ @app.command("run")
113
+ @click.argument("task")
114
+ @options
115
+ def run_command(task: str, **kwargs) -> None:
116
+ """Run one generic agent task."""
117
+ runtime = _build_runtime(factory, **kwargs)
118
+
119
+ async def execute() -> None:
120
+ try:
121
+ result = await runtime.run(task)
122
+ click.echo(result)
123
+ finally:
124
+ await runtime.aclose()
125
+
126
+ asyncio.run(execute())
127
+
128
+
129
+ def _register_repl_command(
130
+ app: click.Group,
131
+ factory: RuntimeFactory,
132
+ options: RuntimeOptionsDecorator,
133
+ ) -> None:
134
+ @app.command("repl")
135
+ @options
136
+ def repl_command(**kwargs) -> None:
137
+ """Start a generic persistent agent session."""
138
+ runtime = _build_runtime(factory, **kwargs)
139
+
140
+ async def execute() -> None:
141
+ try:
142
+ while True:
143
+ line = (await asyncio.to_thread(input, "inquirycraft> ")).strip()
144
+ if line.lower() in {"exit", "quit", "q"}:
145
+ break
146
+ if line:
147
+ click.echo(await runtime.run(line))
148
+ except (EOFError, KeyboardInterrupt):
149
+ pass
150
+ finally:
151
+ await runtime.aclose()
152
+
153
+ asyncio.run(execute())
154
+
155
+
156
+ def _register_tool_commands(app: click.Group) -> None:
157
+ @app.group("tools")
158
+ def tools_group() -> None:
159
+ """Inspect built-in tools without initializing an LLM."""
160
+
161
+ @tools_group.command("list")
162
+ @click.option("--json-output", "json_output", is_flag=True)
163
+ def tools_list(json_output: bool) -> None:
164
+ tools = default_tools()
165
+ if json_output:
166
+ click.echo(json.dumps({"schema_version": "1.0", "tools": tools.names()}, indent=2))
167
+ else:
168
+ for tool_name in tools.names():
169
+ click.echo(tool_name)
170
+
171
+ @tools_group.command("schema")
172
+ @click.argument("name", required=False)
173
+ def tools_schema(name: str | None) -> None:
174
+ tools = default_tools()
175
+ schemas = tools.schemas()
176
+ if name:
177
+ schemas = tuple(row for row in schemas if row["function"]["name"] == name)
178
+ if not schemas:
179
+ raise click.ClickException(f"Unknown tool: {name}")
180
+ click.echo(json.dumps({"schema_version": "1.0", "tools": schemas}, indent=2))
181
+
182
+
183
+ def _register_event_commands(app: click.Group) -> None:
184
+ @app.group("events")
185
+ def events_group() -> None:
186
+ """Inspect InquiryCraft runtime event JSONL."""
187
+
188
+ @events_group.command("show")
189
+ @click.argument("path", type=click.Path(path_type=Path, exists=True, dir_okay=False))
190
+ @click.option("--type", "event_type", default=None)
191
+ @click.option("--limit", type=click.IntRange(1), default=None)
192
+ def events_show(path: Path, event_type: str | None, limit: int | None) -> None:
193
+ events = load_events(path, event_type=event_type)
194
+ if limit is not None:
195
+ events = events[-limit:]
196
+ for event in events:
197
+ click.echo(json.dumps(event.to_dict(), ensure_ascii=False, sort_keys=True))
198
+
199
+
200
+ def create_cli(
201
+ *,
202
+ name: str = "inquirycraft",
203
+ runtime_factory: RuntimeFactory | None = None,
204
+ extensions: Iterable[CliExtension] = (),
205
+ model_envvar: str | Sequence[str] = "INQUIRYCRAFT_MODEL",
206
+ base_url_envvar: str | Sequence[str] = "INQUIRYCRAFT_BASE_URL",
207
+ api_key_env_default: str = "INQUIRYCRAFT_API_KEY",
208
+ ) -> click.Group:
209
+ factory = runtime_factory or DefaultRuntimeFactory()
210
+
211
+ @click.group(name=name)
212
+ def app() -> None:
213
+ """Run and inspect lightweight InquiryCraft agents."""
214
+
215
+ options = _runtime_options(
216
+ model_envvar=model_envvar,
217
+ base_url_envvar=base_url_envvar,
218
+ api_key_env_default=api_key_env_default,
219
+ )
220
+ _register_run_command(app, factory, options)
221
+ _register_repl_command(app, factory, options)
222
+ _register_tool_commands(app)
223
+ _register_event_commands(app)
224
+
225
+ registry = CommandRegistry(app)
226
+ for extension in extensions:
227
+ extension.register(registry)
228
+ return app
229
+
230
+
231
+ main = create_cli()