Skip to content

Commit 8a18fe5

Browse files
authored
Add logger to Agent (#51)
+ propagate logs from local tools + add debug logs
1 parent a9cbdc7 commit 8a18fe5

9 files changed

Lines changed: 410 additions & 23 deletions

File tree

splunklib/ai/README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ def tool(ctx: ToolContext) -> None:
196196
In this example, the `Logger` instance is accessed via `ctx.logger` and used to emit an informational
197197
log message during tool execution.
198198

199+
These logs are forwarded to the `logger` passed to the `Agent` constructor.
200+
199201
### Tool filtering
200202

201203
Tools can be filtered, before these are made available to the LLM, via the `tool_filters` parameter.
@@ -514,6 +516,35 @@ condition (`TokenLimitExceededException`, `StepsLimitExceededException` or `Time
514516

515517
These limits apply over the entire lifetime of an `Agent`.
516518

519+
## Logger
520+
521+
The `Agent` constructor accepts an optional logger parameter that enables detailed
522+
tracing and debugging throughout the agent’s lifecycle.
523+
524+
```py
525+
from splunklib.ai import Agent, OpenAIModel
526+
from splunklib.ai.hooks import token_limit, step_limit, timeout_limit
527+
from splunklib.client import connect
528+
import logging
529+
530+
model = OpenAIModel(...)
531+
service = connect(...)
532+
533+
logger = logging.getLogger("test")
534+
logger.setLevel(logging.DEBUG)
535+
536+
async with Agent(
537+
model=model,
538+
service=service,
539+
system_prompt="..." ,
540+
logger=logger,
541+
) as agent: ...
542+
```
543+
544+
The agent emits logs for events such as: model interactions, tool calls, subagent calls.
545+
546+
Additionally logs from local tools are also forwarded to this logger.
547+
517548
## Known issues
518549

519550
### CA - File not found

splunklib/ai/agent.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# License for the specific language governing permissions and limitations
1414
# under the License.
1515

16+
from logging import Logger
1617
import os
1718
from collections.abc import Sequence
1819
from typing import Self, final, override
@@ -99,15 +100,17 @@ class Agent(BaseAgent[OutputT]):
99100
Description of the agent when used as a subagent. This is
100101
surfaced to the supervisor and used to decide whether this agent
101102
is appropriate for a given task. Ignored for top-level agents.
103+
104+
logger:
105+
Optional logger instance used for tracing and debugging the agent’s execution.
106+
Additionally logs from the local tools are forwarded to this logger.
102107
"""
103108

104109
_impl: AgentImpl[OutputT] | None
105110
_use_mcp_tools: bool
106111
_service: Service
107112
_tool_filters: ToolFilters | None
108113

109-
# TODO: We should have a logger inside of an agent, debugging and such.
110-
111114
def __init__(
112115
self,
113116
model: PredefinedModel,
@@ -121,6 +124,7 @@ def __init__(
121124
hooks: Sequence[AgentHook] | None = None,
122125
name: str = "", # Only used by Subgents
123126
description: str = "", # Only used by Subagents
127+
logger: Logger | None = None,
124128
) -> None:
125129
super().__init__(
126130
model=model,
@@ -131,6 +135,7 @@ def __init__(
131135
input_schema=input_schema,
132136
output_schema=output_schema,
133137
hooks=hooks,
138+
logger=logger,
134139
)
135140

136141
if duplicate_hook_names := _find_duplicate_hook_names(self.hooks):
@@ -145,14 +150,27 @@ async def __aenter__(self) -> Self:
145150
if self._impl:
146151
raise AssertionError("Agent is already in `async with` context")
147152

153+
if self.name:
154+
self.logger.debug(f"Creating agent {self.name}; trace_id={self.trace_id}")
155+
else:
156+
self.logger.debug(f"Creating agent; trace_id={self.trace_id}")
157+
148158
if self._use_mcp_tools:
149159
self._tools = await _load_tools_from_mcp(
150-
self._service, self._tool_filters, self.trace_id
160+
self._service,
161+
self._tool_filters,
162+
self.trace_id,
163+
self.logger,
151164
)
152165

153166
backend = get_backend()
154167
self._impl = await backend.create_agent(self)
155168

169+
if self.name:
170+
self.logger.debug(f"Agent {self.name} created; trace_id={self.trace_id}")
171+
else:
172+
self.logger.debug(f"Agent created; trace_id={self.trace_id}")
173+
156174
return self
157175

158176
async def __aexit__(self, exc_type, exc_value, traceback) -> None: # noqa: ANN001 # pyright: ignore[reportUnknownParameterType, reportMissingParameterType]
@@ -171,6 +189,7 @@ async def _load_tools_from_mcp(
171189
service: Service,
172190
filters: ToolFilters | None,
173191
trace_id: str,
192+
logger: Logger,
174193
) -> list[Tool]:
175194
local_tools_path = _testing_local_tools_path
176195
app_id = _testing_app_id
@@ -186,10 +205,16 @@ async def _load_tools_from_mcp(
186205
if not os.path.exists(local_tools_path):
187206
local_tools_path = None
188207

189-
mcp_tools = await load_mcp_tools(service, local_tools_path, app_id, trace_id)
208+
mcp_tools = await load_mcp_tools(
209+
service, local_tools_path, app_id, trace_id, logger
210+
)
190211
if filters:
191212
return filter_tools(mcp_tools, filters)
192213

214+
logger.debug(
215+
f"Tools loaded & filtered successfully; tools_after_filtering={[tool.name for tool in mcp_tools]}"
216+
)
217+
193218
return mcp_tools
194219

195220

splunklib/ai/base_agent.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
from abc import ABC, abstractmethod
1717
from collections.abc import Sequence
18+
import logging
1819
import secrets
1920
from typing import Generic
2021

@@ -37,6 +38,7 @@ class BaseAgent(Generic[OutputT], ABC):
3738
_output_schema: type[OutputT] | None = None
3839
_hooks: Sequence[AgentHook] | None = None
3940
_trace_id: str
41+
_logger: logging.Logger
4042

4143
def __init__(
4244
self,
@@ -49,6 +51,7 @@ def __init__(
4951
input_schema: type[BaseModel] | None = None,
5052
output_schema: type[OutputT] | None = None,
5153
hooks: Sequence[AgentHook] | None = None,
54+
logger: logging.Logger | None = None,
5255
) -> None:
5356
self._system_prompt = system_prompt
5457
self._model = model
@@ -61,9 +64,19 @@ def __init__(
6164
self._hooks = tuple(hooks) if hooks else ()
6265
self._trace_id = secrets.token_hex(16) # 32 Hex characters
6366

67+
if logger is None:
68+
# Create a no-op logger to skip checking for its existence.
69+
logger = logging.Logger(name="fake", level=logging.CRITICAL + 100)
70+
assert len(logger.handlers) == 0
71+
self._logger = logger
72+
6473
@abstractmethod
6574
async def invoke(self, messages: list[BaseMessage]) -> AgentResponse[OutputT]: ...
6675

76+
@property
77+
def logger(self) -> logging.Logger:
78+
return self._logger
79+
6780
@property
6881
def system_prompt(self) -> str:
6982
return self._system_prompt

splunklib/ai/engines/langchain.py

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,18 @@
1313
# License for the specific language governing permissions and limitations
1414
# under the License.
1515

16+
import logging
1617
import uuid
1718
from collections.abc import Sequence
1819
from dataclasses import dataclass
1920
from functools import partial
2021
from time import monotonic
21-
from typing import Any, cast, override
22+
from typing import Any, Awaitable, Callable, cast, override
2223

2324
from langchain.agents import create_agent
2425
from langchain.agents.middleware import (
2526
AgentMiddleware as LC_AgentMiddleware,
27+
wrap_tool_call,
2628
)
2729
from langchain.agents.middleware import (
2830
AgentState as LC_AgentState,
@@ -40,13 +42,15 @@
4042
from langchain.messages import ToolCall as LC_ToolCall
4143
from langchain.messages import ToolMessage as LC_ToolMessage
4244
from langchain.tools import ToolException as LC_ToolException
45+
from langchain.tools.tool_node import ToolCallRequest as LC_ToolCallRequest
4346
from langchain_core.language_models import BaseChatModel
4447
from langchain_core.messages.base import BaseMessage as LC_BaseMessage
4548
from langchain_core.messages.utils import count_tokens_approximately
4649
from langchain_core.tools import BaseTool, StructuredTool
4750
from langgraph.checkpoint.memory import InMemorySaver
4851
from langgraph.graph.state import CompiledStateGraph, RunnableConfig
4952
from langgraph.runtime import Runtime
53+
from langgraph.types import Command as LC_Command
5054

5155
from splunklib.ai.base_agent import BaseAgent
5256
from splunklib.ai.core.backend import (
@@ -58,9 +62,8 @@
5862
from splunklib.ai.hooks import (
5963
AgentHook,
6064
AgentState,
61-
StepsLimitExceededException,
62-
TimeoutExceededException,
63-
TokenLimitExceededException,
65+
after_model as hook_after_model,
66+
before_model as hook_before_model,
6467
)
6568
from splunklib.ai.messages import (
6669
AgentCall,
@@ -192,12 +195,28 @@ async def create_agent(
192195

193196
system_prompt = AGENT_AS_TOOLS_PROMPT + "\n" + system_prompt
194197

195-
middleware = []
198+
before_user_hooks, after_user_hooks, before_user_lc_middlewares = (
199+
_debugging_middleware(agent.logger)
200+
)
201+
202+
middleware = [
203+
_convert_hook_to_middleware(h, model_impl) for h in before_user_hooks
204+
]
205+
middleware.extend(before_user_lc_middlewares)
206+
207+
# User-provided hooks go in between our hooks.
196208
if agent.hooks:
197209
middleware.extend(
198-
(_convert_hook_to_middleware(h, model_impl) for h in agent.hooks)
210+
(
211+
_convert_hook_to_middleware(h, model_impl, logger=agent.logger)
212+
for h in agent.hooks
213+
)
199214
)
200215

216+
middleware.extend(
217+
(_convert_hook_to_middleware(h, model_impl) for h in after_user_hooks)
218+
)
219+
201220
return LangChainAgentImpl(
202221
system_prompt=system_prompt,
203222
model=model_impl,
@@ -207,6 +226,73 @@ async def create_agent(
207226
)
208227

209228

229+
def _debugging_middleware(
230+
logger: logging.Logger,
231+
) -> tuple[list[AgentHook], list[AgentHook], list[LC_AgentMiddleware]]:
232+
# TODO: These names can conflict with user-provided names.
233+
234+
# TODO: replace this with ours middleware, once we add them.
235+
@wrap_tool_call # pyright: ignore[reportArgumentType, reportCallIssue, reportUntypedFunctionDecorator]
236+
async def _tool_call(
237+
request: LC_ToolCallRequest,
238+
handler: Callable[
239+
[LC_ToolCallRequest], Awaitable[LC_ToolMessage | LC_Command[None]]
240+
],
241+
) -> LC_ToolMessage | LC_Command[None]:
242+
call = _map_tool_call_from_langchain(request.tool_call)
243+
244+
tool_or_agent = "Tool"
245+
if isinstance(call, AgentCall):
246+
tool_or_agent = "Agent"
247+
248+
logger.debug(f"{tool_or_agent} call {call.name} stared; id={call.id}")
249+
try:
250+
result = await handler(request)
251+
assert isinstance(result, LC_ToolMessage)
252+
253+
if result.status == "success":
254+
logger.debug(
255+
f"{tool_or_agent} call {call.name} succeeded; id={call.id}"
256+
)
257+
else:
258+
logger.debug(f"{tool_or_agent} call {call.name} failed; id={call.id}")
259+
260+
return result
261+
except Exception:
262+
logger.debug(f"{tool_or_agent} call {call.name} failed; id={call.id}")
263+
raise
264+
265+
before_user_lc_middlewares = [_tool_call]
266+
267+
@hook_after_model
268+
def _debug_after_model(state: AgentState) -> None:
269+
last = state.response.messages[-1]
270+
if isinstance(last, AIMessage):
271+
tool_calls = [
272+
(call.name, call.id)
273+
for call in last.calls
274+
if isinstance(call, ToolCall)
275+
]
276+
subagent_calls = [
277+
(call.name, call.id)
278+
for call in last.calls
279+
if isinstance(call, AgentCall)
280+
]
281+
logger.debug(
282+
f"LLM model invocation ended; requested_tool_calls={tool_calls}; requested_subagent_calls={subagent_calls}"
283+
)
284+
285+
before_user_hooks = [_debug_after_model]
286+
287+
@hook_before_model
288+
def _debug_before_model(state: AgentState) -> None:
289+
logger.debug("Invoking LLM model")
290+
291+
after_user_hooks = [_debug_before_model]
292+
293+
return before_user_hooks, after_user_hooks, before_user_lc_middlewares # pyright: ignore[reportReturnType]
294+
295+
210296
def _create_langchain_tool(tool: Tool) -> BaseTool:
211297
async def _tool_call(
212298
**kwargs: dict[str, Any],
@@ -389,6 +475,7 @@ def _map_message_to_langchain(message: BaseMessage) -> LC_BaseMessage:
389475
def _convert_hook_to_middleware(
390476
hook: AgentHook,
391477
model: BaseChatModel,
478+
logger: logging.Logger | None = None,
392479
) -> LC_AgentMiddleware:
393480
match hook.type:
394481
case "before_model":
@@ -414,6 +501,10 @@ def _middleware(state: LC_AgentState, runtime: Runtime) -> dict[str, Any] | None
414501
# the token counting function as part of the Backend interface, so that
415502
# it's only used when needed instead.
416503
sdk_state = _convert_agent_state_from_langchain(state, model)
504+
505+
if logger:
506+
logger.debug(f"Executing {hook.type} hook {hook.name}")
507+
417508
hook(sdk_state)
418509

419510
return wrapper(_middleware)

splunklib/ai/messages.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,14 @@
2727
class ToolCall:
2828
name: str
2929
args: dict[str, Any]
30-
id: str | None
30+
id: str | None # TODO: can be None?
3131

3232

3333
@dataclass(frozen=True)
3434
class AgentCall:
3535
name: str
3636
args: dict[str, Any]
37-
id: str | None
37+
id: str | None # TODO: can be None?
3838

3939

4040
@dataclass(frozen=True)

splunklib/ai/registry.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,9 @@ def service(self) -> Service:
179179
def logger(self) -> Logger:
180180
"""
181181
This logger can be used by tools to emit logs during execution of a tool.
182+
183+
Logs emitted using this logger are forwarded to the logger
184+
provided to the agent constructor.
182185
"""
183186
assert self._logger is not None
184187
return self._logger

0 commit comments

Comments
 (0)