lucid-graphql 0.2.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.
lucid/__init__.py ADDED
@@ -0,0 +1,67 @@
1
+ """lucid — natural language to GraphQL, driven by an agentic workflow."""
2
+
3
+ from importlib.metadata import PackageNotFoundError
4
+ from importlib.metadata import version as _version
5
+
6
+ from lucid.client import (
7
+ EventStream as EventStream,
8
+ )
9
+ from lucid.client import (
10
+ LucidClient as LucidClient,
11
+ )
12
+ from lucid.client import (
13
+ QueryStream as QueryStream,
14
+ )
15
+ from lucid.client import (
16
+ ask as ask,
17
+ )
18
+ from lucid.client import (
19
+ ask_stream as ask_stream,
20
+ )
21
+ from lucid.client import (
22
+ create as create,
23
+ )
24
+ from lucid.client import (
25
+ generate as generate,
26
+ )
27
+ from lucid.client import (
28
+ generate_stream as generate_stream,
29
+ )
30
+ from lucid.errors import (
31
+ LucidError as LucidError,
32
+ )
33
+ from lucid.errors import (
34
+ QueryExecutionError as QueryExecutionError,
35
+ )
36
+ from lucid.errors import (
37
+ QueryValidationError as QueryValidationError,
38
+ )
39
+ from lucid.errors import (
40
+ SchemaError as SchemaError,
41
+ )
42
+ from lucid.errors import (
43
+ TransportError as TransportError,
44
+ )
45
+ from lucid.models import (
46
+ Event as Event,
47
+ )
48
+ from lucid.models import (
49
+ Response as Response,
50
+ )
51
+ from lucid.models import (
52
+ RunMetrics as RunMetrics,
53
+ )
54
+ from lucid.transport import (
55
+ ExecutionResult as ExecutionResult,
56
+ )
57
+ from lucid.transport import (
58
+ HttpxTransport as HttpxTransport,
59
+ )
60
+ from lucid.transport import (
61
+ Transport as Transport,
62
+ )
63
+
64
+ try:
65
+ __version__ = _version("lucid-graphql")
66
+ except PackageNotFoundError: # running from a source tree without an installed dist
67
+ __version__ = "0.0.0+unknown"
lucid/agent.py ADDED
@@ -0,0 +1,302 @@
1
+ """The Strands agent that authors a query, plus the guardrails around it.
2
+
3
+ ``stream_workflow`` is the single instrumented core: it yields progress
4
+ ``Event``s while the agent works and returns the final workflow state. The
5
+ non-streaming entry points simply drain it, so the two can never diverge.
6
+ """
7
+
8
+ import asyncio
9
+ import contextvars
10
+ import logging
11
+ import queue
12
+ import threading
13
+ from collections import deque
14
+ from collections.abc import AsyncGenerator, AsyncIterator, Generator, Iterator
15
+ from pathlib import Path
16
+ from typing import Any, cast
17
+
18
+ from graphql import GraphQLSchema
19
+ from strands import Agent
20
+ from strands.hooks import AfterToolCallEvent, BeforeToolCallEvent, HookProvider, HookRegistry
21
+ from strands.models import Model
22
+ from strands.types.exceptions import EventLoopException
23
+
24
+ from lucid.errors import LucidError, QueryExecutionError, QueryValidationError
25
+ from lucid.models import Event, RunMetrics
26
+ from lucid.tools import WorkflowState, build_tools
27
+ from lucid.transport import Transport
28
+
29
+ logger = logging.getLogger("lucid")
30
+
31
+ _SYSTEM_PROMPT = """\
32
+ You write a single valid GraphQL query that answers the user's question.
33
+
34
+ Use the tools to explore the schema before writing the query. Never guess type
35
+ or field names — look them up with list_root_fields, search_schema and get_type.
36
+
37
+ Rules:
38
+ - Start from list_root_fields to find the right entry point.
39
+ - Select only the fields needed to answer the question; do not over-fetch.
40
+ - Fields whose type is an interface or union require inline fragments
41
+ (`... on ConcreteType`) for type-specific fields.
42
+ - You MUST call {goal_tool} with your query and only finish once it succeeds.
43
+ If it reports errors, fix the query and try again.
44
+ - When done, reply with exactly the final query text and nothing else.
45
+ """
46
+
47
+ _NUDGE = (
48
+ "You have not yet produced a query that {goal}. "
49
+ "Use the tools to fix the query, call {goal_tool} until it succeeds, "
50
+ "then reply with the final query text."
51
+ )
52
+
53
+
54
+ def _system_prompt(goal_tool: str, instructions: str | None) -> str:
55
+ prompt = _SYSTEM_PROMPT.format(goal_tool=goal_tool)
56
+ if instructions:
57
+ prompt += f"\nAdditional guidance from the application:\n{instructions}\n"
58
+ return prompt
59
+
60
+
61
+ class _IterationGuard(HookProvider):
62
+ """Stops the run with a typed error once the correction cap is exhausted."""
63
+
64
+ def __init__(self, state: WorkflowState, max_iterations: int) -> None:
65
+ self._state = state
66
+ self._max_iterations = max_iterations
67
+
68
+ def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None:
69
+ registry.add_callback(AfterToolCallEvent, self._after_tool_call)
70
+
71
+ def _after_tool_call(self, event: AfterToolCallEvent) -> None:
72
+ if isinstance(event.exception, LucidError):
73
+ # The tool executor re-invokes this hook with our own raise attached;
74
+ # re-raising is what makes it propagate out of the event loop.
75
+ raise event.exception
76
+ if event.exception is not None:
77
+ return
78
+ if self._state.validation_failures >= self._max_iterations:
79
+ raise QueryValidationError(
80
+ f"no valid query after {self._state.validation_failures} validation attempts",
81
+ query=self._state.last_query,
82
+ errors=self._state.last_errors,
83
+ )
84
+ if self._state.execution_failures >= self._max_iterations:
85
+ raise QueryExecutionError(
86
+ f"execution still failing after {self._state.execution_failures} attempts",
87
+ query=self._state.last_query,
88
+ errors=self._state.last_errors,
89
+ )
90
+
91
+
92
+ def _tool_message(name: str, tool_input: dict[str, Any]) -> str:
93
+ match name:
94
+ case "list_root_fields":
95
+ return "listing the root query fields"
96
+ case "search_schema":
97
+ return f"searching the schema for {tool_input.get('keyword', '?')!r}"
98
+ case "get_type":
99
+ return f"inspecting type {tool_input.get('name', '?')}"
100
+ case "validate_query":
101
+ return "validating a candidate query"
102
+ case "execute_query":
103
+ return "executing the query"
104
+ return f"calling {name}"
105
+
106
+
107
+ class _EventCollector(HookProvider):
108
+ """Translates tool-call hooks into ``tool`` and ``attempt`` events."""
109
+
110
+ _ATTEMPT_TOOLS = ("validate_query", "execute_query")
111
+
112
+ def __init__(self, state: WorkflowState, pending: deque[Event]) -> None:
113
+ self._state = state
114
+ self._pending = pending
115
+ self._attempts = 0
116
+
117
+ def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None:
118
+ registry.add_callback(BeforeToolCallEvent, self._before_tool_call)
119
+ registry.add_callback(AfterToolCallEvent, self._after_tool_call)
120
+
121
+ @staticmethod
122
+ def _tool_input(event: BeforeToolCallEvent | AfterToolCallEvent) -> dict[str, Any]:
123
+ tool_input = event.tool_use.get("input")
124
+ return tool_input if isinstance(tool_input, dict) else {}
125
+
126
+ def _before_tool_call(self, event: BeforeToolCallEvent) -> None:
127
+ name = event.tool_use["name"]
128
+ self._pending.append(
129
+ Event(kind="tool", message=_tool_message(name, self._tool_input(event)))
130
+ )
131
+
132
+ def _after_tool_call(self, event: AfterToolCallEvent) -> None:
133
+ if event.exception is not None or event.tool_use["name"] not in self._ATTEMPT_TOOLS:
134
+ return
135
+ self._attempts += 1
136
+ query = self._tool_input(event).get("query")
137
+ if self._state.last_errors:
138
+ error = "; ".join(self._state.last_errors)
139
+ message = f"attempt {self._attempts} failed: {error}"
140
+ else:
141
+ error = None
142
+ verb = "executed" if event.tool_use["name"] == "execute_query" else "validated"
143
+ message = f"attempt {self._attempts}: query {verb} successfully"
144
+ self._pending.append(
145
+ Event(
146
+ kind="attempt",
147
+ message=message,
148
+ iteration=self._attempts,
149
+ query=query,
150
+ error=error,
151
+ )
152
+ )
153
+
154
+
155
+ def _metrics(agent: Agent, state: WorkflowState) -> RunMetrics:
156
+ usage = agent.event_loop_metrics.accumulated_usage
157
+ return RunMetrics(
158
+ input_tokens=usage["inputTokens"],
159
+ output_tokens=usage["outputTokens"],
160
+ total_tokens=usage["totalTokens"],
161
+ cycles=agent.event_loop_metrics.cycle_count,
162
+ iterations=state.validation_failures + state.execution_failures,
163
+ )
164
+
165
+
166
+ def _goal_reached(state: WorkflowState, execute: bool) -> bool:
167
+ if execute:
168
+ return state.executed_query is not None
169
+ return state.validated_query is not None
170
+
171
+
172
+ def _drain_async(items: AsyncIterator[dict[str, Any]]) -> Iterator[dict[str, Any]]:
173
+ """Minimal sync bridge over the agent's async event stream (prototype).
174
+
175
+ The whole stream is consumed by a single task on a dedicated thread and
176
+ items are handed back over a queue. Step-driving the generator with one
177
+ ``run_until_complete`` per item would resume it in a fresh Task (and thus
178
+ a fresh contextvars Context) each time, which corrupts context-scoped
179
+ state such as OpenTelemetry spans. The caller's context is carried into
180
+ the thread so tracing stays parented correctly.
181
+ """
182
+ results: queue.Queue[tuple[str, Any]] = queue.Queue()
183
+ stop = threading.Event()
184
+ context = contextvars.copy_context()
185
+
186
+ async def consume() -> None:
187
+ try:
188
+ async for item in items:
189
+ results.put(("item", item))
190
+ if stop.is_set():
191
+ break
192
+ finally:
193
+ if isinstance(items, AsyncGenerator):
194
+ await items.aclose()
195
+
196
+ def run() -> None:
197
+ try:
198
+ # asyncio.run (vs a bare event loop) matters: it cancels pending
199
+ # tasks and finalizes abandoned async generators (e.g. provider
200
+ # SDK streams) via shutdown_asyncgens before closing the loop.
201
+ asyncio.run(consume())
202
+ results.put(("done", None))
203
+ except BaseException as error: # handed to the consuming thread to re-raise
204
+ results.put(("error", error))
205
+
206
+ thread = threading.Thread(target=lambda: context.run(run), name="lucid-stream", daemon=True)
207
+ thread.start()
208
+ try:
209
+ while True:
210
+ kind, value = results.get()
211
+ if kind == "done":
212
+ return
213
+ if kind == "error":
214
+ raise value
215
+ yield value
216
+ finally:
217
+ stop.set()
218
+ thread.join()
219
+
220
+
221
+ def _stream_turn(agent: Agent, prompt: str, pending: deque[Event]) -> Generator[Event]:
222
+ """Run one agent turn, yielding thinking/tool/attempt events as they happen."""
223
+ thinking: list[str] = []
224
+
225
+ def flush_thinking() -> Iterator[Event]:
226
+ text = "".join(thinking).strip()
227
+ thinking.clear()
228
+ if text:
229
+ yield Event(kind="thinking", message=text)
230
+
231
+ def flush_pending() -> Iterator[Event]:
232
+ yield from flush_thinking()
233
+ while pending:
234
+ yield pending.popleft()
235
+
236
+ stream = _drain_async(aiter(agent.stream_async(prompt)))
237
+ try:
238
+ for item in stream:
239
+ if pending:
240
+ yield from flush_pending()
241
+ if "reasoningText" in item:
242
+ thinking.append(str(item["reasoningText"]))
243
+ elif "data" in item:
244
+ thinking.append(str(item["data"]))
245
+ except EventLoopException as error:
246
+ if isinstance(error.original_exception, LucidError):
247
+ raise error.original_exception from error
248
+ raise
249
+ yield from flush_pending()
250
+
251
+
252
+ def stream_workflow(
253
+ question: str,
254
+ *,
255
+ schema: GraphQLSchema,
256
+ sdl_path: Path,
257
+ model: Model,
258
+ max_iterations: int,
259
+ transport: Transport | None = None,
260
+ instructions: str | None = None,
261
+ ) -> Generator[Event, None, tuple[WorkflowState, RunMetrics]]:
262
+ """Run the agentic workflow for one question, yielding progress events.
263
+
264
+ With a ``transport`` the goal is a successfully executed query (ask mode);
265
+ without one it is a successfully validated query (generate mode). Optional
266
+ ``instructions`` are appended to the built-in system prompt to steer
267
+ generation (the workflow contract itself is not overridable). Returns
268
+ the final state and metrics via the generator's return value.
269
+ """
270
+ execute = transport is not None
271
+ goal_tool = "execute_query" if execute else "validate_query"
272
+ state = WorkflowState()
273
+ pending: deque[Event] = deque()
274
+ agent = Agent(
275
+ model=model,
276
+ tools=cast(list[Any], build_tools(schema, sdl_path, state, transport=transport)),
277
+ system_prompt=_system_prompt(goal_tool, instructions),
278
+ hooks=[_IterationGuard(state, max_iterations), _EventCollector(state, pending)],
279
+ callback_handler=None,
280
+ )
281
+
282
+ yield from _stream_turn(agent, question, pending)
283
+ nudges = 0
284
+ while not _goal_reached(state, execute) and nudges < max_iterations:
285
+ nudges += 1
286
+ logger.debug("goal not reached; nudging agent (attempt %d)", nudges)
287
+ goal = "executes successfully" if execute else "validates"
288
+ yield from _stream_turn(agent, _NUDGE.format(goal=goal, goal_tool=goal_tool), pending)
289
+
290
+ if not _goal_reached(state, execute):
291
+ if execute and state.validated_query is not None:
292
+ raise QueryExecutionError(
293
+ "agent finished without successfully executing a query",
294
+ query=state.last_query,
295
+ errors=state.last_errors,
296
+ )
297
+ raise QueryValidationError(
298
+ "agent finished without a validated query",
299
+ query=state.last_query,
300
+ errors=state.last_errors,
301
+ )
302
+ return state, _metrics(agent, state)
lucid/cli.py ADDED
@@ -0,0 +1,58 @@
1
+ """Command-line interface: ask a GraphQL endpoint a question.
2
+
3
+ Runnable via ``uvx --from "lucid-graphql[anthropic]" lucid ...`` or after
4
+ ``uv tool install "lucid-graphql[anthropic]"``.
5
+ """
6
+
7
+ import json
8
+
9
+ import click
10
+
11
+ from lucid.client import create
12
+ from lucid.errors import LucidError
13
+ from lucid.models import Event
14
+
15
+
16
+ @click.command()
17
+ @click.argument("question")
18
+ @click.option("--url", "-u", required=True, help="The GraphQL endpoint.")
19
+ @click.option(
20
+ "--generate",
21
+ "-g",
22
+ "generate_only",
23
+ is_flag=True,
24
+ help="Print the generated query instead of executing it.",
25
+ )
26
+ @click.option(
27
+ "--verbose",
28
+ "-v",
29
+ is_flag=True,
30
+ help="Write the agent's progress (thinking, tools, attempts) to stderr.",
31
+ )
32
+ def main(question: str, url: str, generate_only: bool, verbose: bool) -> None:
33
+ """Turn a natural-language QUESTION into a GraphQL query and run it against --url.
34
+
35
+ Prints the query result as JSON (or, with --generate, the query itself)
36
+ to stdout. Uses the default Anthropic model; set ANTHROPIC_API_KEY.
37
+ """
38
+ # ask/generate are just their stream drained, so the stream form with
39
+ # on_event=None is identical to the plain call — no need to branch.
40
+ on_event = _echo_progress if verbose else None
41
+ try:
42
+ client = create(url)
43
+ if generate_only:
44
+ click.echo(client.generate_stream(question, on_event=on_event).result())
45
+ else:
46
+ response = client.ask_stream(question, on_event=on_event).result()
47
+ click.echo(json.dumps(response.data, indent=2))
48
+ except LucidError as error:
49
+ click.echo(f"Error: {error}", err=True)
50
+ raise SystemExit(1) from error
51
+
52
+
53
+ def _echo_progress(event: Event) -> None:
54
+ click.echo(event.message, err=True)
55
+
56
+
57
+ if __name__ == "__main__":
58
+ main()