Skip to content

Use Agent Docstring and Module Docstring as Agent system prompt#585

Merged
eb8680 merged 14 commits intomasterfrom
dn-agent-systemprompt
Feb 24, 2026
Merged

Use Agent Docstring and Module Docstring as Agent system prompt#585
eb8680 merged 14 commits intomasterfrom
dn-agent-systemprompt

Conversation

@datvo06
Copy link
Contributor

@datvo06 datvo06 commented Feb 23, 2026

This PR attempts to partially address #567 for agent:

  1. Adds an automated system prompt from the agent by concatenating the module docstring with the agent subclass docstring.
  2. Also added test cases to ensure an invariant that there is only one system prompt in each call to the agent. Haven't added parameterized tests yet as there are no cases that non-agent template can see agent templates.

Some subtleties that might need adjustment:

  1. Do we allow agent prompt to use the lexical context? (currently no)
  2. Should Module code or Agent code appear first in the system prompt (right now: module -> agent).
  3. Agent class in notebook might not have access to module docstring.

template,
"__system_prompt__",
textwrap.dedent(f"""
SYSTEM: You are a helpful LLM assistant named {template.__name__}.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we just remove this hard-coded default given the degradation it seems to have caused in #567 ? We want the system message to be fully modifiable by the user, so falling back to the module docstring in Template.define might be better.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While updating it I realize that this might not work in notebook as notebook would not have module doc. For Agent, it's fine as Template can still use Agent's docstring, but not normal templates, so I used another prompt instead.

"""Build an Agent system prompt from module and class docstrings."""
parts: list[str] = []

mod = inspect.getmodule(cls)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part with the module-level docstring should probably happen in Template.define, so that mod is the module of the Template and this covers non-method Templates as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved it into Template.define


mod = inspect.getmodule(cls)
if mod is not None and mod.__doc__:
parts.append(inspect.cleandoc(mod.__doc__))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if it makes a difference for modules, but you might want to use inspect.getdoc here instead of mod.__doc__.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used inspect.getdoc now.

if mod is not None and mod.__doc__:
parts.append(inspect.cleandoc(mod.__doc__))

class_doc = cls.__dict__.get("__doc__")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you use inspect.getdoc here instead of just looking at the __doc__ attribute, it will automatically go up the inheritance hierarchy to find the first non-empty class docstring. Note that that would in some cases be the docstring of Agent itself, which we may or may not want (or we may want to change the Agent docstring so that it is more amenable to being used this way).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated to use inspect.getdoc here as well.

Comment on lines 393 to 397
class_doc = cls.__dict__.get("__doc__")
if class_doc is None or not inspect.cleandoc(class_doc):
raise ValueError(
"Agent subclasses must define a non-empty class docstring."
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we want this to be an error. As I wrote in one of my other comments, using inspect.getdoc rather than cls.__doc__ would look at the inheritance hierarchy of cls and get the first non-empty docstring, which would always include Agent as a fallback.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also updated it, as it has fallback, I dont need to throw ValueError anymore.

__system_prompt__: str

@classmethod
def _build_system_prompt(cls) -> str:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would just inline this logic into Agent.__init_subclass__ / Template.define so that it's not something that can be overridden in subclasses. There are already two ways of customizing the system prompt (docstrings and handling call_system), having a third is unnecessarily confusing.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I inlined it.

prop.__set_name__(cls, "__history__")
cls.__history__ = prop
if not hasattr(cls, "__system_prompt__"):
sp = functools.cached_property(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this PR, __system_prompt__ is actually fixed at the class rather than instance level, despite being an instance-level cached_property. Are there cases where we might want __system_prompt__ to vary across instances of the same Agent class? If so, how?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's an interesting question. I think we might actually want to have agent of same class but behave slightly differently.
For example, agents Alice and Bob communicating with each other might want to know who they are. We could make Alice and Bob subclass. But that stop us from mass-producing these agents.
We could also move __system_prompt__ to be at init level or a property definable by user.

Copy link
Contributor

@eb8680 eb8680 Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bound method Templates already have access to Agent instance-level data in user messages via self, so I'm not sure identifiers like "alice"/"bob" are the best example. It seems to me that something should only go in the system message if it satisfies some or ideally all of the following properties:

  1. Constant and immutable at an Agent class or instance level (so that the system message can't change during the lifetime of an Agent instance)
  2. Naturally associated with an Agent, Template or module rather than a data type or Tool (so that we aren't duplicating structured input/output specifications already handled by Encodable or Tool)
  3. Identifiable mechanically from runtime introspection (so that we aren't making arbitrary internal choices for the system prompt that lead to repeats of Call_system should be more emphasized by Template docstring. #567 )
  4. Large or complicated (so that repeatedly encoding the same information across user messages would be too wasteful)
  5. Unconditionally useful for any call to any method Template (so that the system prompt isn't inlining something that should be gated behind a tool call)

There are a number of things that are not instance-specific that satisfy these properties (e.g. module source code), but the only specifically instance-level things that come to mind immediately are:

  • Annotated fields that are not explicitly accessed by any Template and are known somehow to be immutable e.g. a tuple[Image, ...] field on a dataclass. More generally I guess you could imagine an Annotation for use on Agent class definitions that asserts that some instance-level field should be appended to the system message of an instance. This seems likely to be especially useful for non-textual data.
  • Compactified message history - as with ordinary coding agents or chatbots, once an Agent's __history__ gets to be too long for the underlying model, it has to be compressed somehow to continue using it. One way to do this is by having the model summarize it and append the resulting constant-size summary to the system message before clearing __history__.

Both of those are speculative and out of scope for this PR, though.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eb8680
Copy link
Contributor

eb8680 commented Feb 23, 2026

Do we allow agent prompt to use the lexical context? (currently no)

I'd say no, since we don't want the system message to change once it's been set. Template methods should already be able to refer to variables in the enclosing scope around the class definition. In #545 I included the source of the module via inspect.getsource(inspect.getmodule(template)), which gives some information about the lexical context, but I think there are too many open design questions around doing that to include it in this more conservative PR.

Should Module code or Agent code appear first in the system prompt (right now: module -> agent).

Module before Agent seems logical. Would you expect this to matter much in practice?

Agent class in notebook might not have access to module docstring.

This seems fine, not all regular modules have docstrings either.

@eb8680
Copy link
Contributor

eb8680 commented Feb 23, 2026

there are no cases that non-agent template can see agent templates.

I think this should just work as expected with the design in this PR (because Templates shouldn't ever see message histories other than their own or their Agent's) but this seems like an important edge case to include in the tests.

@datvo06
Copy link
Contributor Author

datvo06 commented Feb 23, 2026

Should Module code or Agent code appear first in the system prompt (right now: module -> agent).

Module before Agent seems logical. Would you expect this to matter much in practice?

I think it might, but we don't know until we have cases that break because of wrong order, so keeping this until then

@datvo06
Copy link
Contributor Author

datvo06 commented Feb 23, 2026

Agent class in notebook might not have access to module docstring.

This seems fine, not all regular modules have docstrings either.

Got it. It's fine for the agent. But I think Template should still have some system prompt if it's in REPL or notebook environment. So I added the fallback in call_system:

"You are a helpful assistant, you need to follow user's instruction"

@datvo06
Copy link
Contributor Author

datvo06 commented Feb 23, 2026

I think this should just work as expected with the design in this PR (because Templates shouldn't ever see message histories other than their own or their Agent's) but this seems like an important edge case to include in the tests.

I added test to make sure that this is true.
Update: after adding the test, I realized that the current logic allow Template to collect agent tools in scope as well. So I'm adding a guard condition. Will think again tonight.

@eb8680
Copy link
Contributor

eb8680 commented Feb 23, 2026

Update: after adding the test, I realized that the current logic allow Template to collect agent tools in scope as well. So I'm adding a guard condition. Will think again tonight.

Maybe I misunderstood, but I interpreted your earlier comment about this to be about histories rather than tools? I don't think we'd want to change the current tool collection behavior to make the new test you added pass.


# Collect tools as methods on Agent instances in context
elif isinstance(obj, Agent):
elif include_agent_instance_tools and isinstance(obj, Agent):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should not be changed, the existing behavior seems correct to me.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. Reverted!

return standalone, captured_agent

standalone, _ = make_template()
assert "helper" not in standalone.tools
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only problem I see here is that the name should be "helper_agent.helper" or "captured_agent.helper" rather than "helper". Otherwise I don't think this test should pass, the Agent's methods should be visible to standalone.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood. I removed this test.

assert MissingDocAgent.__doc__ is None
prompt = MissingDocAgent().__system_prompt__
assert prompt
assert "persistent LLM message history" in prompt
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hardcoded substring of the current Agent.__doc__ will cause the test to break immediately upon changes to Agent.__doc__.

assert_single_system_message_first(mock.received_messages[0])
assert (
mock.received_messages[0][0]["content"]
== "You are a helpful assistant, you need to follow user's instruction"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hardcoded value will break this test immediately upon any changes to default behavior of call_system.

assert agent_doc is not None
assert prompt == agent_doc

def test_blank_docstring_uses_inherited_doc(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is failing, but it's also just a very weak and brittle test and should be deleted entirely. Not all tests are created equal.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to merge once this is addressed.

@eb8680 eb8680 merged commit befba34 into master Feb 24, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants