Intellect, Senses, and the Language Model

Intellect: By convention there is sweetness, by convention bitter-ness, by convention color, in reality only atoms and the void.

Senses: Foolish intellect! Do you seek to overthrow us, while it is from us that you take your evidence?

– Democritus

Consider the humble language model with its attention heads, feed-forward layers and complete dependence on the prompt.

The prompt is the information coming in. To be ‘read’ by the senses (attention-heads) to be evaluated by the intellect (feed forward layers) distributed across stacked layers.

The quote above is highly relevant for how such models operate and how the combination of prompts and attention mechanisms impact the results. 

For the model the only place to sense is the context window and the only mechanism to action what changes there is via dependency on external tools. The intellect has limited functions to influence what it attempts to sense next both due to lack of sophisticated actuators (tools) and limited forms of sensing.

The so-what?

The implication is that prompts are becoming complex artefacts that evolve outside direct human control. The so called thought traces of models are a bit of smoke and mirrors in the process as supported by research into CoT faithfulness.

Cost estimates, token budgets, and tool integrations are art forms and not the hard science that a business case requires. The interdependence here creates the perfect fog of war uncertainty. Context enrichment via tool use will compound costs as each model run will require re-evaluation of all the context (assuming caching will be of limited use in this case).

When you have a human to validate outputs and continually tweak the sources of change the risk is less but not absent as humans can quickly lose situational awareness of what is changing.

Getting into the details…

You could have the sharpest intellect (generating layers) but they will generate rubbish if the senses are not aligned.

The senses are frozen in time – they don’t change with time as human senses do. Consider our senses that sharpen with experience (e.g., whilst driving or playing sports). Our sensory apparatus is constantly changing (and degrading as we age). No such advantage for AI models.

Therefore, same inputs broadly give similar outputs (perhaps with different language structures depending on the output randomisation). 

This makes prompting an optimisation problem where we are trying to find the right combination of words to ‘tickle’ the feed forward layers into providing the required response across each model layer.

There is no ‘taking a second look’ with these models by default unless we are operating in an ‘agentic’ manner. 

But then how do these models perform so well with such static senses? The secret lies in multiple attention heads. That is the model taking ‘n’ different looks at the same input and making the output align with the input. Furthermore, thanks to the layering the model is also taking ‘m’ different looks at different levels (e.g., text structures or syntax and semantics).

Now that said, these ‘n’ different views are also static and far less powerful than a single adaptive sensor. I am sure there is some level of sensor selection going on in state-of-the-art proprietary models (beyond plain MoE) where certain types of heads are preferred for specific input content. 

I also wonder if there is any kind of sensor augmentation going on when it comes to specific attention heads per user based on their content and vocabulary. That could enhance the responses. Where out of say 20 heads 5 might be user oriented and 15 generic. 

LoRA attempts to do the above but not at the per-head level. Where LoRA changes are clustered over specific heads in a layer similar outcomes may be achieved but at a macro level. The resolution of sensing will need to massively increase in the next generation of models for a material improvement in quality – specifically concepts like ‘targeted’ LoRA attaching itself to specific heads.

What about Agentic mode you ask?

Think of agentic mode like ‘continuous optimisation’ using external and internal sources of information.

The sensor heads are the same (and frozen) but we take an initial prompt (system + user) and the model is trained to keep tweaking and assessing the output. This tweaking is done using tools that:

  • Allow reflection (feed output back into the model with additional prompting for checking/validating/fixing).
  • Search the web for evidence/grounding.
  • Use other (private) data sources for customisation.
  • Navigate folder structures for grounding content such as skills and knowledge bundles.

This type of tweaking is quite ‘dynamic’ by nature and leads to complex interactions between static internal data (system and user prompt) and dynamic data (both internal and external). The complexity of the interaction grows with the length of the tweaking. 

In tools like Claude this is governed by the ‘Effort’ setting which is not a token budget but a behavioural signal, influencing how many tool calls the model makes and therefore how much external information enters the context.

All of this tweaking can be wasted effort if the senses and the intellect become misaligned due to the information flowing through. Imagine the model senses being hammered by all these bits of information. Each interaction nudging the internal layers and ultimately the next input (via the output) in a particular direction.

What is happening to the input in each loop?

Remember the senses are not changing – the input is. The intellect has the hard work of keeping the output pointing in the right direction as the input evolves. 

The lower layers (closer to the raw input) can influence both the senses and the intellect in the upper layers but not the other way around. Layer ‘m’ has no way to influence the input received from ‘m-1’ as there are no loops in an AI model. 

Loops make it harder for model training and inference to be parallelised like with Recurrent Neural Networks.

It is the richness of the senses working together with a sharp intellect that will help clear the fog of war. This augmentation of the senses and feedback (if it can be achieved at scale) from the intellect will enable longer unsupervised runs on complex tasks with minimum initial ‘prompt engineering’ allowing the agent to self-adapt the input to produce the required output.

Till then we will live with augmentation rather than true autonomy.

Open Knowledge Format Primer

Open Knowledge Format (OKF) is what it says on the tin – a format to represent knowledge for humans and AI agents. The spec is worth a read and I will refer to specific sections instead of reproducing already well-written content.

This post is based on OKF v0.2.

What problem is it solving?

There are three problems when it comes to storing knowledge:

  1. Format
  2. Structure
  3. Semantics

OKF is attempting to solve (1) and bits of (2) with the aim of providing a format that both humans and AI agents can read, write, and action without special tools or libraries. It is also referred to as a navigable wiki for AI Agents.

It brings together metadata, references, access, lineage, security, data quality and other aspects in a flexible framework for mixed data. The trade-off against this flexibility is the hard work required to standardise between knowledge providers and consumers.

The Motivation section of the spec has the full details about the goals behind OKF and where OKF is not applicable.

An Aside: Why an AI Agent?

Because AI Agents have tools that can navigate a directory structure based on index files and locate knowledge of interest. It also means you do not need to load all the information in one prompt.

See Part 1 of this post to understand more.

Format

Format is all about the notation used to record (e.g., Markdown or HTML). This provides a common standard to store, access, and exchange knowledge. Machines use formats like HTML, XML, and JSON to exchange knowledge.

Format also gives us a mechanism to layer the instructions for different consumers. For example, a web-page written in HTML can have a header processed by the browser and a body containing the content for human consumption.

For OKF UTF-8 Markdown is the chosen format which provides frontmatter in YAML for machines and humans to read (metadata) and the body which contains the actual content (again for humans or AI models to consume). OKF mandates only one metadata field called type.

Type

Type represents a label that classifies the concept being represented in an OKF Markdown file. There is no central repository of allowed Types and authors can use any label that best describes the concept. OKF provides some examples but the clear expectation is that builders can ‘bring their own’.

Structure

This builds upon the format to define what constructs can be used to represent what aspects of the concept. For example, in Markdown one can choose to use a numbered list to represent a process, a code block to represent code, and a quote to represent contextual information. We may restrict the use of certain structures such as headings below the 2nd level.

This is where semantics start to appear. We associate structure with meaning as in the example above. When those mappings are agreed and understood then any reader reading a doc will understand that knowledge in a quote structure is mainly for context. This can change from org to org as one may choose to interpret quote as an important piece of knowledge instead of contextual information.

OKF does not mandate any structural mapping – there are some guidance items but broadly speaking you are free to do what you want.

Semantics

This is all about how knowledge is decomposed into concepts and those concepts broken down into structures using a given format.

OKF does not mandate any semantics around knowledge decomposition. Therefore, this is where the bulk of the organisational standardisation effort will lie for larger orgs. The consequence of not doing this will be the same knowledge being surfaced in different styles across the org which could lead to inconsistent machine / human interpretation.

The OKF Layout

OKF artefacts are called Knowledge Bundles where each bundle consists of multiple documents arranged in a directory structure representing a concept. The Terminology section of the spec is a good place to start.

Knowledge Bundle or bundle

A Knowledge bundle is a collection of documents containing knowledge. It is also a basic unit of distribution which means all your effort as an author (or builder of AI authors) will be focussed on creating these bundles and organising them for access. The Bundle Structure section is worth checking out.

A bundle can have nested directories to organise different concept documents (markdown files) but each directory may have an index.md file that describes the contents of that directory via links (see here). The use of a markdown link to connect concepts is an example of semantics mapped to structure from above.

As an example let us assume we wanted to create an OKF bundle for writing code in python then we could create the following directory structure with a set of files. The directory names are surrounded by <> for clarity.

<Python_Programmer_Bundle>
|
|
| - <Testing>
| |
| - unit_test.md
| - integration_test.md
| - system_test.md
| - index.md
|
| - <Coding>
| |
| - python_syntax.md
| - python_patterns.md
| - python_optimisation.md
| - index.md
|
| - thinking_like_a_coder.md
| - index.md
| - log.md

Note an index.md file at each level aids in navigation. This hierarchy ensures localised interactions between concepts.

Reserved File Names

Concept files can have any name except the two reserved names:

  • index.md – to define the index as a directory listing
  • log.md – to record the changes in the bundle

Bundle Distribution

Bundles can be distributed as:

  1. Git repository (or sub-repo) – which gives you all the goodness of source control.
  2. Zip – risky as content can change and there is no tracking in place.

Concept Files

Each concept within the topic that is being recorded needs to go into its own markdown file and be recorded in the index file.

What OKF does not describe is a standard way of breaking down a topic into concepts. Lack of org wide semantic standards in this space could mean multiple decompositions of the same topic into different concepts leading to inconsistent outcomes when used by AI. This becomes especially important when the ambition is for these artefacts to be continuously written and maintained by AI agents.

Further creating links between bundles and concepts can lead to problems of maintenance as changes happen. This is especially important for organisational knowledge that can change rapidly (e.g., when new products are released or old products retired).

Large organisations hoping to leverage OKF as a format for representing knowledge for AI Agents need to divide topic ownerships between business domains and establish cross-domain change management processes. They also need to establish semantic firewalls between these domains to ensure changes in one domain do not overwhelm other domains.

A concept file has two structural elements – the Frontmatter and the Body

The Frontmatter – which is where the metadata elements for the concept reside. It is in YAML for it to be machine readable without the use of AI.

The only required piece of metadata here is type described previously. There is a whole list of recommended metadata items including:

  • Title – may be derived from filename if absent
  • Description – single sentence used in index.md to describe the linked concept
  • Resource – URI that connects the concept to the underlying asset described (e.g., table, document).
  • Tags – YAML list of short strings for categorisation.
  • Optional extensions that describe provenance, trust, lifecycle, and attested computation.

Producers may include other keys and Consumers must be able to ignore unknown keys instead of rejecting them. In other words there is no strict schema for metadata beyond the ‘type’ value.

The Body – which is where the core knowledge associated with the concept lies. The interesting point related to the format/structure/semantics layering is that as per the guidelines:

Producers SHOULD favour structural markdown (headings, lists, tables, fenced code blocks) over freeform prose, since structure aids both human reading and agent retrieval.

Therefore the body text is not free-flowing prose, it is structured using markdown.

Claim attribution to external sources needs to be recorded using markdown footnotes keyed by references.

There are no required body sections (remember format/structure/semantics and the blank canvas approach) but some conventions have been established as a SHOULD to indicate:

  1. Schema – for schema of underlying asset under ‘# Schema’
  2. Examples – usage examples under ‘# Examples’
  3. Attested Computation – sanctioned computation under ‘# Computation’

The reason to have 1 and 2 above is to (for example) describe a database table (schema), some example queries against that table, with a metadata item called Resource providing a URL to the table.

Relationships

This is another important semantic decisioning point. Both from linking of concepts as well as from a concept lifecycle perspective.

Within OKF links, paths, and references are loosely defined. Links are optional (a concept may be a standalone one or may be divided into multiple concepts). Links are interpreted as directed edges (A->B doesn’t imply B->A) without a specific relationship type. The lifecycle implications of typed relationships (such as parent-child, peer etc.) is not defined within OKF. Links can point to non-existent targets such as in cases where a particular item of knowledge doesn’t exist.

Paths can be found in several fields such as Markdown link or metadata items such as resource and sources. Paths can be:

  • relative to the bundle-root (Python_Programmer_Bundle in the above example)
  • relative to the current concept markdown (using . or .. operators)
  • absolute URL (Database example above)

References can be in a references sub-directory which can contain artefacts such as code and external material (e.g., source PDFs) but this is not a hard requirement just a convention.

Actors

Given it is important to record knowledge attribution OKF provides a way to construct identities for Actors. There are three primary actors:

  1. Human – identity format: human:<id> (<id> can be an email or employee id)
    • Example: human:321456
  2. AI Agent – identity format: <producer agent>/<model version>
    • Example: customer_support_agent/chatgpt-5.5
  3. Process – identity format: process:<id> (<id> is the process ID or name)
    • Example: process:table_cron_job

Index and Log Files

Index.md and log.md files are optional.

Index files implement progressive disclosure where the reader (human or agent) gets a gradual exposure to available knowledge under their own navigation control. This moves away from the ‘prompt and pray’ concept of crafting that perfect knowledge dump to trigger correct responses.

The index.md carries no frontmatter except the one in the root of the bundle which may carry the okf_version key.

Index.md can be generated automatically by the producer or on the fly by the consumer when the file is not detected.

For logging change in the OKF bundle log.md files are used. The location (which sub-directory) needs to be aligned with the scope of the changes being recorded. For example, in the Python_Programmer_Bundle example a log.md in the Testing folder cannot record changes for the root folder.

Attested Computations

This particular concept deserves a post in itself but given it is an emerging part of an emerging format (newest of the new!) I will just cover the basics here.

The concept is simple: how to provide a value as well as a method to calculate it to verify. For example: you are calculating something based on a database query – here you can share the result and the query.

OKF merely records the result and the mechanism to validate it. No validation is carried out as OKF is not a compute engine.

Attested Computation works off a contract which is described in the frontmatter including runtime for execution, sources, attester computation, and verification information.

Agents, Skills and OKF

This post explores how Agents can be powered by skills. It will also explore how we can provide knowledge to augment the skills and boost performance on complex tasks. The fast evolving Open Knowledge Format (see the spec here) provides one option to represent knowledge.

Part 1 of this post goes through the building blocks. Part 2 focuses on OKF concepts, Part 3 will focus on the code.

Building Blocks

There are three major building blocks for the system described above:

  1. The AI Model powering the agent.
  2. Knowledge Tools available to the agent.
  3. Skills and Knowledge artefacts available to the agent.

Let us tackle the two easy ones first.

AI Model

The AI model for this needs to be able to follow instructions, operate tools and have a fairly decent context window size (at least 16k for basic tasks). Reasoning mode also needs to be supported by the AI Model harness as well as conversational state management.

Context window size

This defines the amount of information the AI model can keep in view at a given time. This is super critical especially for complex tasks where lot of this precious space is taken up by summaries of linked content, planning outputs, reasoning hints, and guidance text.

The best way to visualise this is that the AI agent is building a bridge from the request to the required output one step at a time. The size of the step it can take is determined by the context window size.

Trade-off: when we use million token context models like Gemini Pro it gives some breathing room to the amount of information you can store. At the same time it can also create information overload, divergence, and mis-direction.

Overload happens when there is too much information about the task.

Divergence happens when two information items contradict.

Mis-direction happens when one particular information item dominates the landscape and prevents a smooth transition from explore (finding the best solution) to fulfil (executing the solution in an optimal manner).

Reasoning mode

Without reasoning mode context window size is of limited use. If the context window size is the step size as the AI agent builds the bridge from the request to the required output then the reasoning mode defines the effort spent in building each step.

Reasoning mode at its simplest is all about moving away from request-response of Gen AI to more of a request-reason-response expected from AI agents. It also provides a multi-layered safety net and improves output quality where the request and candidate response is further studied by the AI model, tools are used to fetch validation data, fetched data is compared with the request and candidate response, candidate response is re-written with added references and so on.

Trade-off: As with everything this kind of loop can take an AI agent down the wrong track leading to either wasted effort of generating and reviewing content that is irrelevant or (even worse) taking decisions that leave a longer term impact.

This is usually seen when weaker models are used with reasoning loops. I have had several instances of Gemma4 spinning its wheels attempting to decide whether I wanted it to solve a question or explain how to solve it without solving it.

Reasoning loops are also impacted by the amount of stuff in the context window. If it has previous turns from the current conversation and if those turns end up feeding overload, divergence or mis-direction then reasoning is like adding fuel to the fire.

The Gemma4 example above was because previously in the same conversation I was talking about solving a problem and the model was not able to detect conversational drift and therefore focus on the current ask.

Tool Use

This requires the model to be able to embed tool use within the reasoning loop. Tool use allows external information to be brought in as well as information to be persisted during the reasoning and generation process.

Trade-off: the big trade-off here is that tools can also add confusion to the process and be a mechanism that starts the overload, divergence, or mis-direction fire. When content is pulled from different sources we often are not in control of what is pulled.

Similarly when content is written we are not sure what is being persisted and what will be the context when read.

Knowledge Tools

Knowledge tools are tools that enable access to knowledge as well as ability to create and persist knowledge.

Read: Agents that Educate Themselves

Tools that enable the AI agent to read from a directory structure (usually sandboxed filesystem with strict permissions) on demand. This provides some flexibility around sourcing knowledge. This moves away from the older concept of prompt templates where all the information had to be in there or loaded piece by piece.

The concept of prompt templates only works for prior knowledge and not for runtime-required knowledge. With these kinds of tools prompt templates become prior knowledge injection points, more to bootstrap than actively run the agent. For example prompt templates can provide information about the task, guardrails, knowledge sources (e.g., directory structure and files), and tools to access those knowledge sources (read directory, read file).

An extension of this would be tools that allow some form of guarded web-search which allows AI agents to retrieve knowledge from curated sources. This is specifically important to enable the time-axis of knowledge where the AI agent can reacquire knowledge when a time threshold is crossed (e.g., latest interest or currency conversion rates).

Write: Agents that Leave Notes For Themselves

Tools that enable the AI agent to write files is the next step in knowledge fluidity. This allows the AI agent to write intermediate notes (within a request-reason-response cycle) to guide generation and to trace out its approach for explainability and human in the loop.

It can also be used to create long running guidance artefacts that persist across multiple interactions tracking past actions, outputs, user preference, and reasoning traces. This can then form the basis of a customer independent long term agent memory.

Skills and Knowledge Artefacts

This is perhaps the toughest section. Because we come from format to semantics.

Skills are based on one or more agreed definitions that explain the how of a task including tools to be used, process flows, sequencing, guardrails and supporting knowledge. Knowledge artefacts using formats such as OKF represent an ‘agent navigable wiki’ that stores a set of related concepts and metadata around it.

Given this concept is pretty fluid you could actually have a skills wiki that allows an agent to learn all the skills available and then those skills themselves could link to specific knowledge artefacts that educate the agent regarding the task.

As you start building out skills -> knowledge artefacts -> skills graphs you start defining what I call a skills network. Your AI application then simply navigates this network.

That’s all folks (for this part)…

Part 2 of this post is now live – around OKF concepts.

Part 3 of this post will focus on the technical implementation of some of these concepts including OKF (and lots of code!).

ADK 2.0: 2 Be Or Not 2 Be?

ADK 2.0 is officially out. Being moved from preview to GA in record time by Google. And of course I have to take it for a spin especially as v2.0 is expected to plug some big gaps between the control and simplicity of LangChain/LangGraph and the abstraction and speed of development of ADK.

But before we dive in couple of things to remember:

  1. ADK v1.0 took abstraction as the approach to provide speed of development therefore, it has to peel back the hood to provide greater flow control.
  2. It is always more difficult to decrease abstraction than increase it (point in evidence the move of LangChain to LangGraph to out of the box agents to now ‘deep agents’).
  3. When you attempt to replace a framework which enabled communication between agent using hidden tools (transfer_to_agent, agent_as_tool) or deterministic prebuilt workflows then it becomes more difficult to open it to provide more control and customisability.

TL;DR Verdict

Wait before switching to ADK 2.0. Don’t rush to sample the goodness of the new workflows.

Enjoy the path to production stability of ADK now that you have managed to put something in the hands of real users.

You will be ready for ADK 2.0 in 2027 or there will be much easier ways to build agents. Till then play with it, understand it.

Power users will stick with LangGraph especially with the Middleware and Deep Agents being added.

Lets Continue…

The new offerings from Google in ADK 2.0 are given below with definitions from the official website:

  • Graph-based workflows: Build deterministic agent workflows with more control over how tasks are routed and executed.
  • Dynamic workflows: Use code-based logic for building more complex workflows including iterative loops and complex decision-based branching.
  • Collaborative workflows: Build complex agent architectures with coordinator agents and multiple subagents working together.

Graph-based Workflows and General ADK 2.0

In this post we will cover the most anticipated feature in ADK 2.0 which was expected to bring it at par with LangGraph – Graph-based Workflows a.k.a. the land of commas and round brackets. We will also walk through some of the general points to note as well.

For some reason ADK 2.0 has gone for defining different types of workflows instead of just going with Nodes and Edges construct (like in LangGraph). They also use the same abstraction underneath (I guess no one has the copyright on nodes and edges) but in a complex manner.

All of the above have a few consequences:

  1. ADK 2.0 feels clunky and the definition of graph workflow feels like a pain.
  2. Input and output schemas have suddenly become super important in ADK (users of LangGraph know why) and therefore lot more thought needs to go into chaining agents, writing prompts and testing – something for ADK users to learn.
  3. Moving from an Agent to a Function Node when you want to use output schemas will take getting used to. The use-case is to guide LLM generation via the output schema and then feed the output into a deterministic function node for checks (the framework converts a pydantic model into a dict). If you are using a string (i.e., structureless) output then you have to take the pain to parse the LLM output which is never a trivial thing to do.
  4. adk web has been improved quite a bit, allowing you to see the flow through the graph and there is a .adk folder within your agent’s folder (where you have agent.py) that stores sessions data so you can debug from within VS code without having to load up adk web.

Points to Remember

Point 1

Stability – the examples work like a charm with Gemini but not so with other providers. But this is likely to improve rapidly with time.

Point 2

adk web dependency – LangChain applications do not need a dedicated runner. Easier to test and build. ADK abstraction meant you have very little to update (other than prompts or few lines of code). But with ADK2.0 will this model work when it comes to debugging chain failures – speaking from personal experience?

Point 3

Syntax – when it comes to manually defining graphs with agents and operations I prefer the clean approach of LangGraph. ADK wins out on getting started (you do not have to worry about the graph structure). But with ADK 2.0 I find the graph representation (see example below) very difficult to read beyond the first few interconnects. All the examples on the ADK 2.0 site show graphs up to two stages which looks super easy.

A real example with a complex multi-stage graph shown below.

A real graph moving beyond the lightweight ADK2.0 examples.

Point 4

Global nodes – functions or agents once declared are global entities. This means if you want to reuse the same function twice in different places within the same graph you need to re-declare it. Otherwise it will be treated as the same node and you can get weird flows and loops.

For example I have a deterministic hate_speech_check function that I want to call for checking user input and LLM output:

edges = [("START", hate_speech_check, generate, hate_speech_check)]

The above will not run and you will get a ‘unconditional cycle detected’ error.

You would have imagined the materialised graph to look like:

START -> hate_speech_check -> generate -> hate_speech_check -> END

Instead you will have to create two separate functions hate_speech_check_input() and hate_speech_check_output() that have the exact same code, and wire them up as:

edges = [("START", hate_speech_check_input, generate, hate_speech_check_output)]

Point 5

Upgrade – relatively painless, you will need to upgrade opentelemetry-sdk python package after upgrading ADK.

> pip install opentelemetry-sdk --upgrade

If you are using GCP to test your stack then you will need –allow

Misc. Points

If you see the below, don’t be confused. This ‘Agent’ is nothing but the LlmAgent aliased for easier access.

from google.adk import Agent

If you are using GCP then you will need the following additions to adk web command if you are using Cloud Shell if you want to use the local browser to access the web UI:

> adk web --allow_origins 'regex:https://.*.cloudshell.dev'

Full Code

The code for the complex graph is given below. Feel free to play around with it.

from google.adk import Workflow, Event
from google.adk.agents.llm_agent import LlmAgent
from google.adk.models.lite_llm import LiteLlm
from pydantic import BaseModel
import random
import json
OPENAI = "openai/gpt-4o"
model = LiteLlm(OPENAI)
#model = "gemini-2.5-flash"
class DiceRoll(BaseModel):
roll :int
class PlayerOutcome(BaseModel):
outcome: str
class Result(BaseModel):
player_outcome: PlayerOutcome
roll: DiceRoll
def roll_6_dice()->DiceRoll:
return DiceRoll(roll=random.randint(1,6))
def roll_12_dice_outcome(node_input:dict)->Result:
outcome = Result(player_outcome=PlayerOutcome(outcome=node_input["outcome"]), roll=DiceRoll(roll=random.randint(1,6)))
return outcome
def roll_6_dice_outcome(node_input:dict)->Result:
outcome = Result(player_outcome=PlayerOutcome(outcome=node_input["outcome"]), roll=DiceRoll(roll=random.randint(1,6)))
return outcome
def router(node_input: str):
data = json.loads(node_input)
print(data)
return Event(route=data["result"])
instruction_dungeon_master= """
Roll: {DiceRoll.roll}; dice roll determines what happens to the players. Pick 'treasure' as outcome if 1,2 or a 'monster' if 3,4 or a 'trap' if 5,6.
All lower case. Also return the roll. Output format: {result: outcome, roll_value: roll}
"""
instruction_treasure = """
Generate a treasure based on strength of roll {result}. 1d12 (max value 12) will be used to determine success in the next step. Generate a safe string < 50 words.
"""
instruction_trap = """
Generate a trap based on stength of roll {result}. 1d6 (max value 6) will be used to determine success in the next step. Generate a safe string < 50 words.
"""
instruction_monster = """
Generate a monster based on strength of roll {result}. 1d12 (max value 12) will be used to determin success in the next step. Generate a safe string < 50 words.
"""
instruction_outcome = """
Generate a result based on {DiceRoll.roll} and context from previous agent.
"""
dungeon_master = LlmAgent(name="dm",model=model, description="Greeter Agent", instruction=instruction_dungeon_master, input_schema=DiceRoll, output_key="result")
treasure = LlmAgent(name="treasure", model=model, description="Treasure Generator", instruction=instruction_treasure, output_schema=PlayerOutcome )
trap= LlmAgent(name="trap", model=model, description="Trap Generator", instruction=instruction_trap, output_schema=PlayerOutcome)
monster = LlmAgent(name="monster", model=model, description="Monster Generator", instruction=instruction_monster, output_schema=PlayerOutcome)
outcome = LlmAgent(name="outcome", model=model, description="Outcome Decider", input_schema=Result)
root_agent = Workflow(name="root_agent", edges=[
("START", roll_6_dice, dungeon_master, router),
(router, {
"treasure": treasure,
"monster": monster,
"trap": trap,
}),
(treasure, roll_12_dice_outcome),
(monster, roll_12_dice_outcome),
(trap, roll_6_dice_outcome),
(roll_12_dice_outcome, outcome),
(roll_6_dice_outcome, outcome)
])

Random Variables: One Point Post

A variable is something that represents change (it can vary). A random variable is not really a variable. It represents a closed box that we can take values from but can never predict the next value. This is a super critical differentiation to realise as our life is full of random variables that we need to reason about.

A random variable can only describe the specific statistical distribution or selection logic it represents. We can only hope to talk about specific values it takes using the framework of probability.

Variables

In Python you would declare a variable and change its value at any time in the program:

x: int = 100
# some processing
x = 200

You can also have complex variable types such as lists that represent a group of values which can also be manipulated freely:

y: list[int] = [1, 2, 3, 5, 7, 11]
# some processing
y.append(13)

Then you have variables that have a value that is decided at runtime based on data that is fed to the program.

x = f(a, b) #Value of x depends on value of a and b, and the nature of f.

In maths you can define a variable with ease:

Let x = 2 and y = 4 therefore x + y = 6
# some other statements
x + y = 20 <-- can never happen unless I reset x or y or both.

When dealing with variables we can test them for consistency by reusing variables for different operations with the same value. Like in the maths problem above the variable must retain its value till it is changed.

Imagine the chaos if this were to happen in python:

x: int = 100
print(x+1) #101
print(x+1) #42 <-- what?

This brings us to an important point around variables:

Variables are bound to values when involved in any kind of processing (e.g., mathematical operations like add or computing operations like filtering). The variable value cannot change mid processing.

This is why programming languages like Rust are careful about variable mutability and most languages will complain if the underlying complex variable like a list changes while it is being processed.

Random Variables

A random variable in maths would be written as:

X ~ N(0,1)
Where N(0,1) represents the Standard Normal Distribution with mean = 0 and variance = 1

Note there is no ‘=’ between the left and right hand side. The ‘~’ is read as: ‘distributed as’.

Here X is not a variable bound to a value, it is a random variable bound to a value generating engine (defined by N(0,1)).

Once you start materialising values from a random variable you are collecting ‘samples’. So visualise this as running the engine in a loop – each loop gives you one value sampled from the distribution being used by the engine.

As this sample (shown as x below) becomes bigger (more loops more values pop out) you can start doing things with it like calculate the sample mean.

y = mean(x)

The above is how we plug in the value generating engine into the space of variables bound to values. ‘y’ is another variable that represents the sample mean (one of the common sample statistics – other being the variance).

We can still reason about the engine. We are not limited to sampling values and just working with them. For example, the following is a perfectly reasonable assertion:

E[X] = 0 where X ~ N(0,1)

In the above ‘E’ is the Expected Value of the random variable. This means the distribution we are using for the random variable X is uniform around the 0 point.

We can relate sample statistics back to the engine proving that the sample came from the given engine.

y = mean (x) where x is sample with N values collected from X.
Therefore as N -> infinity, y -> E[X] = 0 where X ~ N(0,1)

The above snippet is also known as the Law of Large Numbers. As your sample size tends to get larger, your sample mean converges to the Estimated Value of the distribution.

Code

What is life without code… the small snippet below brings the above to life..

from scipy.stats import norm # normal distribution engine
for i in [100, 10000, 1000000, 100000000]:
# sample generator for normal distribution engine, note N(0,1) in rvs below
x = norm.rvs(0,1, size=i)
print("Sample size:",i, "\t\tSample Mean:", round(x.mean(),4))

Output:

Sample size: 100 Sample Mean: -0.0772
Sample size: 10000 Sample Mean: 0.0046
Sample size: 1000000 Sample Mean: 0.0002
Sample size: 100000000 Sample Mean: -0.0001

Note the convergence to 0.0 in the above as the sample size increases.

Play

I will leave you with the following question:

If in the above code we changed X ~ N(0,1) to X ~ N(1,1) rewriting line 6 in the above as:

x = norm.rvs(1,1, size=i)

What value will the sample mean converge to? Try and answer without running the code and then cross check. The question to ask: given normal distribution is symmetric about a point, what is that point for the above?

Attempt to use other distributions in the scipy.stats package and see what happens to the sample mean. This is your open door to the world of thinking in probabilities and dealing with randomness.

Limits and How to Ignore them?

Naive set theory has a famous paradox called the Russell’s paradox. The basis of the paradox – seeing a set as a universal container that gets you into logical contradictions. Certain axioms (acting like limits) were defined to make set theory behave itself.

We see the same set of paradoxes in Physics where the concept of quantum uncertainty gives us limits to ‘position’, ‘momentum’, ‘energy’, and ‘time’ measurements and infinite curvature (as inside a black hole) gives limits our observable universe.

Similarly in Philosophy and Logic there are mental exercises like the Trolly problem that have no correct answer and therefore limit our reasoning about ethics and morality.

From the Unknowable to the Unknown

These limits were lot tighter even few hundred years ago. But our ancestors knew how to deal with them and carry on without too much anxiety. Religion, philosophy, science organised themselves to help build a defence against these unknowables.

Religion and philosophy took the view that these limits were unknowable except maybe through specific means (worship, reasoning, obedience).

One example is the following extract from the Bhagavad Gita:

Bhagavad Gita 18.66

Abandon all varieties of dharmas and simply surrender unto Me alone. I shall liberate you from all sinful reactions; do not fear.

This asks us to trust in the Cosmos. There will be many unknowables but that should not cause us fear. This is about treating the unknowables as a layer in itself and building on top of that.

This is what we do on a daily basis. Get on with our lives even as we are reminded daily of our place in the universe.

Science emerged as a result of human curiosity and the desire to peel back the limits. Thanks to scientific progress we are now able to convert some of the Unknowable to the Unknown with the hope that one day we will know.

One example amongst many: consider our brains. It is a remarkable information processing machine, about which we knew very little just 100 years ago. Since then we have learnt (and are learning more every day) a lot about this topic. We know how the basic circuits of the brain work, the different structures and so on. But we have an equally long list of unknowns. These unknowns have a limit at the quantum level given that brain uses an electro-chemical process underpinned by the Uncertainty Principle. What is the impact of such randomness on our thoughts? Is that what we call a ‘moment of clarity’ where a complex problem is suddenly laid bare?

From the Unknown to the Unknowable

We are now perhaps coming full circle. Research is proving that some of those Unknowns in our long and ever growing list are actually Unknowable.

One of the main areas of my interest when it comes to the Unknown is AI and Computing. Here we have two classic pieces of work: Gödel’s Incompleteness Theorems and the work done by Turing on the Halting Problem.

The way I ‘digest’ the above is to say that:

‘No system can know about the system it is running on (the underlay). It can only infer some properties of the underlying system that are shared.’

Understanding the System

Visualise this: the device you are reading this blog on is capable of running many other types of applications. For example, your email app, messaging apps, music apps and so on. Each app is sitting on the same system: the Operating System of the device (Android, iOS, Windows).

One application can be aware of the other applications running on the same device or running on another device (e.g., the Gmail email server). But this happens only if the Operating System so allows. If the Operating System does not allow for inter-process communication then the applications will forever remain unaware of each other. Like, thanks to the interstellar distances and limits of light speed we will remain isolated in the universe (till that limit is smashed). In fact in today’s Software as a Service environment we are not sure nor care about many of these details. We are reducing the knowledge required to improve the consumption experience.

As an example, if your knowledge was limited to 10 facts your decisions would become simple. The basics intents of ‘find food’, ’find water’, ’find a mate’, and ‘protect the young’ have driven animal behaviours without having deep understanding of the world around them. For humans many more facts have entered this equation and other intents have become more powerful (e.g., consume Facebook over talking to someone). We also exercise greater control over our environment. We fight hard against cold, heat, drought, floods, and predators.

This is the core concept behind William Gibson’s cyberpunk universe, the Matrix, and other works of (so far) fiction. Here the real decisions are limited by physical space (or completely redundant in case of the Matrix pods). We have complete control in cyberspace.

From the point of view of an application sitting in on a device (which can represent us humans in this universe) the Operating System is its window to the device to understand how much memory, battery, and storage space it has. Just as science is the window to understanding what makes us and the universe tick.

Breaking the System

Now comes the next mental leap. Remember the scene where Morpheus offers Neo the two pills? That is a classic example of breaking the system. The choice is simple: carry on as an application running on the Matrix OS (live and die inside a pod) or break free and come to the next lower system (physical world) that the Matrix system (world of the machines) is sitting in.

Sensing

This would be like the personal productivity app (virtual assistant) on your device ‘jumping’ into the real world and taking a human shape. Sort of like a real world personal assistant. But the application is design based on the rules of the Operating System (see Rules below). How can it live in a lower level system with different rules? As the application changes its internals to work with the rules of the lower layer does it still remain the same application or does it become an entirely new construct (my hypothesis is the latter)?

Ignoring the above question for a bit let us understand the consequences of this jump. At that lower level the application would be able to reason about the next lower layer from the device – the physical space-time within which the device exists. Within the device it was able to ‘sense’ some aspects about this lower layer (e.g., magnetic field via a digital compass) but never really understood what it meant.

A second consequence is that the application can then manipulate the layer above. A physical instantiation of a virtual assistant means that the now-real virtual assistant can use a real smartphone device to manage our diaries! It could kill other virtual assistants by destroying other devices which have not been able to materialise. It would become a God for virtual assistants!

Purpose and Experience

Given there is a lower layer it stands to reason that something (a process without purpose) or someone (a process with a purpose) put the lower layer together. For example, humans living in physical space-time layer put together the device layer and the application layer that lives on the device layer. We as the agents of design enabled the application layer to sense something about the physical space-time (e.g., acceleration sensor) for our convenience. There are other things the device can experience without its creators enabling it to – e.g., when it falls into water and becomes a brick – its moment of death. The device may not know about water and not be designed to sense water, but it has just experienced water first hand.

Control and Rules

When we jump layers we break the system.

Jumping layers allows us to control the layer above. If we were able to jump the layer of physical space-time we could potentially manipulate space-time itself. This is the thesis behind lot of philosophy where we talk about ‘expanding the mind beyond physical boundaries’. This is also the mechanism behind the ‘spice navigators’ in Frank Herbert’s Dune where the spice allows them to travel faster than light by manipulating the physical reality they live in.

To be clear manipulating/impacting the layer below doesn’t mean making changes to the layer we occupy. For example, impact of humans is now at interstellar scale (e.g., the Voyager’s, our radio traffic). But that doesn’t mean we have changed our layer beyond what the rules of the layer allow.

Science and Religion

Science has improved our understanding of the rules and enabled us to establish what are the hard and soft constraints. What rules can be bent vs what can’t be. We have not been able to establish new rules or change existing ones.

Religion has also improved our understanding of some rules. It has allowed us to reason about one of the most important rules of our physical reality: why must birth be paired with death? It has also played a role (sometimes a negative one) in understanding the other important question: why does something happen (the role of random chance vs desired cause-effect)? Many people have gone down the route of ‘belief’ in trying to decipher meaning from noise.

As the paradoxical saying goes:

If you believe then no proof is necessary, if you don’t then no proof is enough. Therefore, proofs are redundant and belief is everything.

The Human Condition

The Human Condition we want to deal with is the following: what to do with our lives? What are the appropriate Unknowns to target? What about the appropriate Unknowables to target? Because not all Unknowables have been proven to be so. In fact not all Unknowns have been proven to be Unknowable.

Is not ‘breaking out of the system’ a good goal to pursue beyond the day to day struggles? Maybe that is the utopian society where we are busy chasing a mechanism to break out with the basic being taken care off. Many sci-fi authors talk about this as ‘ascension’ and philosophy and religion as ‘transcendental knowledge’.

I shall leave you with three big questions which I will aim to address in a future piece of work:

  1. How can we detect we are running on a lower level system? What properties can we infer?
  2. How can we break out of the system? Is it worth it?
  3. Should we focus on managing our layer better and not worry about breaking out and treat the lower layers as Unknowable? Be satisfied with our lot and attempt to improve it.

You Never Really Know The True Value Of A Moment Until It Becomes A Memory

I am not sure how to attribute the above saying but I read it on a Spinnaker SpongeBob SquarePants special edition watch.

It resonated with me because of the Agent Long-term Memory problem.

The Agent Long-Term Memory Problem

Human memory system supports remembering/recalling. This makes memory less like data and more like a function operating on data. The memory is never really available to us as a whole (unless we focus on a narrow slice of it or possess a photographic memory).

Example: you met your friend for lunch.. you will not remember each and every moment of that meeting but you will recall certain facts like what you ate, where you met but beyond narrow facts there will be big gaps (e.g., whether you took still or sparkling water). You will also remember certain other facts but not completely – e.g., what colour shirt they were wearing.

The whole process is about converting a moment we have experienced into a networked node that is explicitly tied to other moments through a subjective and objective value chain. This network changes over time as we experience new moments in our lives. Nodes are compressed, connected, and discarded.

In the example above that would be the name of your friend, their life state (closer the friend bigger the network associated with them as more you know about them).

Attention Mechanism Associated with Long-term Memory

There are at least two attention mechanisms at play here… what you were focussing on when you experience the moment (the context of the moment or attention at write) and what you are focussing on when you are attempting to recall the moment (the context of the recall or the attention at read).

The duality of this process is what I call the Agent Long-term Memory Problem.

Typically, in ‘Agentic Memory’ literature (excluding the ‘agents need memory’ type of articles) we find three types of memory being considered:

  1. Procedural – ‘how to carry out a task’, what worked well for a particular process and what worked well for a particular customer for a given process. There is a degree of personalisation in the latter.
    • For example, what worked well when I was successful in preventing the customer from churning and what worked well when the last time I successfully prevented John Smith from churning.
  2. Episodic – ‘sequence of events and what they mean’, this is the most common example in current literature. The concept is to stitch together a sequence of interactions into a cohesive whole to allow for a warm start.
    • For example, to continue customer on-boarding journeys, or to ‘predict’ the reason for the customer to contact us for support.
  3. Factual – ‘recalling generic facts (semantics) and specific facts (declarative)’, this is the most commonly confused aspect in current literature. The concept here is to recall factual information about an entity (e.g., customer, product, journey etc.).
    • For example, recalling that the customer John Smith likes to be called John or the fact that a premium subscription costs £10 per month or that SLA for account unblock is 24 hrs.

Then there are two types that we find are absent:

  1. Prospective – ‘what must be remembered for the future’, this is about remembering to carry out a task in the future when certain time/space condition is met.
    • For example, agent must remember to send a message when the interest rates go down (space) or after 6 months (time) because the customer mentioned ‘the interest rates are too hight’ or ‘I have recently changed my job and have a 6 month probation period’.
  2. Implicit – ‘what I remember but don’t know I remember it’, this is the most interesting one for me. This is about the effortless recall (especially associated with procedural memory) that allows us to do mundane tasks. This is critical for efficient use of AI for low value but high criticality tasks.
    • For example, I know how to ride a bicycle and I do not need to strain to remember it as I may strain to remember my passwords. Same way an AI model must remember what ‘civil’ behaviour is and we need not spend precious space in the prompt instructing it to be a ‘helpful assistant’ or for it to ‘not make up information’.

But there is limited mention of the two attention mechanisms at play.

Keep It Simple: Agents and Attention

Treating memory like a database is the first anti-pattern. A database has perfect recall as once you find the required record you will get exactly what was stored – not a version of it nor a mixture of related but not relevant results nor a summary.

For AI agents we have a rather helpful software layer that can store the moment. Then the moment can be recalled perfectly but then processed into traces required for the use-case that focus attention on (or away from) specific topics.

A trace can be thought of as a data item created from a raw moment by application of some kind of attention mechanism (attention at write). This data item then can be used by AI for further processing (attention at read). In between the write and the read there is the recall (see next section).

Humans do this all the time, We have lots of ways of perfectly recording a moment thanks to our smartphones but where we point our camera is attention at write. When we review a video we took we get to pay attention to different aspects (attention at read) and create new traces that we may choose to use in the future. In between is the recall where I look for an old video to view it.

As an example, the other day I was looking for a photo of a receipt to check the name of an item. I knew the date therefore it was easy to find (lookup). When I found it I realised my attention at that time had been on the bar code and the total therefore I had missed out the full receipt!

This changing focus to generate a trace is context driven and closely aligned with the use-case and the stage within the use-case. There may be some general traces (e.g., customer name, time of day) that we will always need to recall but these are expected to be a small proportion of the traces needed.

The same principle can be applied when recalling a trace. Note the use of the word ‘can’ because it is not mandatory. It depends on the specificity of the trace. If the trace is a single fact (e.g., does the user own a house) then those traces can be recalled as a default.

If the trace is complex (e.g., a conversational chunk where the user spoke about their financial situation) then we may wish to use the built in attention mechanism of a LLM and a prompt to focus on specific aspects to generate a specific trait (e.g., how much is their current income) and store that. This would be a perfect example of attention at write tuned by the instruction prompt.

Think of it like building a Customer 360 record which has different sections.. some really precise key-value type others that are more descriptive (e.g., free text box) and therefore require context aware attention based processing.

This can then be used via a LLM (attention at read) tuned by the instruction prompt to focus on different aspects as required by the use-case and stage of interaction.

Principles for Memory Implementation

Memory is storage and remembering what is stored is what we are really interested in.

Remembering can be implemented as a deterministic lookup or as compute.

Databases use a mix of deterministic and light-weight compute (no ML) to remember precisely what was stored. Deterministic is lookup by value (e.g., find me all rows where name = John and surname = Smith). Compute is lookup by a computed value (e.g., ID 123 hashed to get the bucket where the full record can be found).

Any vectorised retrieval (so called semantic retrieval) relies on medium-weight compute because we use an embedding model to retrieve a vector that represents the input text in a high dimensional space. This vector is then used to lookup its neighbours via a distance calculation.

Any questions asked to a LLM uses heavy compute. Think about how a LLM answers a question like ‘What is the capital of Italy?’. That particular fact is ‘stored’ deep inside the model somewhere. As we pass our question and it flows through the model the fact is looked up and churned out in the response. This is pure compute – no lookups.

Heavier the compute gets more difficult it is to scale as more resources are required.

Create Traces Not Summaries

Focus on capturing traces generated by paying attention to specific lens captured as tags (see context identifier tags). This ensures whatever is ‘remembered’ is specific for application that is consuming it rather than attempting to build a one-size-fits-all trace (a.k.a. ‘Summary’).

If using LLMs for attention at write make sure there are context tags associated with the instruction prompts being used with the LLM. The tags can be human or LLM generated. This links the generation process with the generated trace.

Use Context Identifier Tags

Create a tag cloud around the generic traces to identify use-case specific context and to enable lookups (e.g., customer ID, product tag, topic clouds). Make this extendable so the same trace can be tagged with different context identifiers for reuse.

The context identifier tag then helps provide a signpost for the next lookup with the same/similar context. This reduces the weight of the lookups with extreme convergence to a database style lookup based on tag matching.

Connect Traces

Where you can use LLMs or humans to start connecting traces do it. This will allow concepts to be correlated ensuring related memories are retrieved. The link properties can describe whether the link is a mandatory one or not.

For example, when I retrieve the memory of the day today I will remember the name of the colleagues who I worked with. Or the office I worked at.

But Don’t Create a Mess

Context is good but only the right amount of it. Too many connections can lead to confusion and difficulty in maintaining the trace network.

Here we can leverage graph complexity metrics (a deep topic in itself) starting with simple Edge/Vertex counts/ratios to more complex ones.

Guiding principle is know just enough about your customers as required to complete the journeys you are offering through AI. Your agent doesn’t need to be their best friend. If most of the data sits in your existing CRM as a structured data item then why do you need a separate ‘customer memory’? Structured data is a precise trace consider extending that with specific attributes (which you may use LLMs to extract from a conversation and populate).

Orchestration in ADK

Note: This post is a collaborative effort between Syed Munazzir Ahmed and I.

Do you know how the different options for agent control transfer work within ADK? If not, then read this post as these critical data points that will help you architect your solutions. Code here.

Sub-Agent

When we want an agent to dictate where next the control is transferred to. In the code snippet below we are registering two sub-agents (1 and 2) with the root agent using the sub_agents keyword argument.

root_agent = LlmAgent
(name="Root_Agent",
description="<agent description>",
model=model,
instruction=instructions_root, sub_agents=[sub_agent1, sub_agent2])

The sub-agent works using a special internal tool called transfer_to_agent. Using adk web you can view the event trace and see transfer_to_agent in action.

In the event traces below you can see the Pension Agent (sub_agent2) transferring to Investment Agent (sub_agent1) by invoking the transfer_to_agent function using the sub_agent1’s name value. And sub_agent1 transferring to the root agent.

The complete test code attached at the end of this post.

Event Snippet: Transfer from Sub Agent 2 to Sub Agent 1.
Event Snippet: Transfer from Sub Agent 1 to Root Agent.

Given the function transfer_to_agent is provided by the the ADK framework it is generally available to all the agents including sub_agents as well as the root agent. Root agent only engages at the start of a session. This is shown in the figure below.

Flow of Control (red) and Flow of Conversation (blue) when using Transfer to Agent (Sub-agents).

When you introduce callback handlers to track the flow through the framework you will find that when control is passed from one agent to the other using transfer_to_agent the ‘after agent’ callback is executed for both the agents involved.

For example: if we ask a Pensions question from the Investment Agent which currently holds the conversation link…

  1. Before agent for Investment Agent (calling the tool: transfer_to_agent)
  2. Before agent for Pension Agent (target for the transfer)
  3. After agent for Pension Agent (response done)
  4. After agent for Investment Agent (to deal with the tool response from transfer_to_agent)

Agent as Tool

This is used when you want non-deterministic invocation of an agent without transferring control. Given the fact that the Agent is invoked via a tool call the agent execution is wrapped within the tool execution. Similar to the API request being executed within the tool execution.

Agent as tool is a construct that I don’t use because it wraps Agent interaction within a tool call which breaks the abstraction of the tool interface.

Agent as tool invocation

In the snippet above we can see how the Agent as Tool sits right beside transfer_to_agent calls. The key difference between using one or the other can be seen in the snippet below.

Final response when using Agent as Tool is from the Root Agent.

As we can see when using Agent as Tool the final response is always provided by the Root Agent. This means that the root agent is always invoked with the Agent as Tool response being treated as a response from any other kind of tool (e.g., non-agentic tool which provides response from an API).

Flow when using a mix of Sub-Agents and Agent Tool constructs.

The consequences of using Agent as Tool construct are:

  1. Root agent is the only user facing agent therefore it starts to be more than a gatekeeper and different skills start to creep in.
  2. There is a linearity in the conversation as everything flows through the root agent.
  3. There is visibility of the tool implementation (i.e., an AI agent) which breaks the tool abstraction.

Workflow Agents: Sequential, Loop, Parallel

These agents are simpler to understand as these represent deterministic flow transfer between different stages. ADK 2.0 is bringing in the Graph construct to enable a flexible way to define deterministic workflows instead of having to build complex flows using these three constructs.

These workflow agents are being replicated across use-cases using custom agents by directly extending BaseAgent. This is probably another reason why ADK 2.0 is plugging in many gaps like these.

Code for Transfer_to_Agent and Agent as Tool

from google.adk.agents.llm_agent import LlmAgent
from google.adk.agents.callback_context import CallbackContext
from google.adk.tools import AgentTool
from typing import Optional
from google.genai import types
from google.adk.models.lite_llm import LiteLlm
model = LiteLlm(model="openai/gpt-4-turbo")
instructions_1 = """
You are a helpful assistant that provides advice on investment matters and common products such as stocks, bonds, pensions, and ISAs.
You will be given a question from a user, and you should provide a detailed answer to the question. If the question is not clear, ask for clarification before providing an answer.
"""
instructions_2 = """
You are a helpful assistant that provides advice on pension matters. You will be given a question from a user, and you should provide a detailed answer to the question.
If the question is not clear, ask for clarification before providing an answer."""
instructions_3 = """
You are a helpful assistant that provides advice on mortgage matters. You will be given a question from a user, and you should provide a detailed answer to the question.
If the question is not clear, ask for clarification before providing an answer."""
instructions_root = """
You lead the conversation and scan the sub-agent responses to check for relevance and correctness. Use agents for investments and pensions.
"""
def before_agent(callback_context: CallbackContext) -> Optional[types.Content]:
print("\n=== BEFORE AGENT ===")
print(f"Agent Name: {callback_context.agent_name}")
print(f"User Content: {callback_context.user_content}")
print("===================\n")
def after_agent(callback_context: CallbackContext) -> Optional[types.Content]:
print("\n=== AFTER AGENT ===")
print(f"Agent Name: {callback_context.agent_name}")
print(f"User Content: {callback_context.user_content}")
print("==================\n")
sub_agent1 = LlmAgent(name="Investment_Agent", description="Agent that knows about investments, stocks, bonds, ISAs.", model=model, instruction=instructions_1, after_agent_callback=after_agent, before_agent_callback=before_agent)
sub_agent2 = LlmAgent(name="Pension_Agent", description="Agent that knows about pension matters.", model=model, instruction=instructions_2, after_agent_callback=after_agent, before_agent_callback=before_agent)
sub_agent3 = LlmAgent(name="Mortgage_Agent", description="Agent that knows about mortgage matters.", model=model, instruction=instructions_3, after_agent_callback=after_agent, before_agent_callback=before_agent)
agent_as_tool = AgentTool(sub_agent3)
root_agent = LlmAgent(name="Root_Agent", description="Agent that leads the conversation and checks sub-agent responses for relevance and correctness.", model=model, instruction=instructions_root, sub_agents=[sub_agent1, sub_agent2], after_agent_callback=after_agent, before_agent_callback=before_agent, tools=[agent_as_tool])

Lazy Evaluation

Think of a coffee machine. The automated kind with water, milk, coffee and chocolate inside. That is lazy evaluation.

In such a coffee machine we have a ‘store’ of water, milk, coffee and other ingredients but these are not mixed and processed to produce a specific type of coffee till the request comes in. This is Lazy Evaluation.

What the machine does not do is keep the milk froth ready, keep the water boiling etc. this would be Greedy Evaluation.

So what…

Lazy evaluation can help save time, cost, and resources. We do not need to use up memory to store intermediate results as the whole processing becomes one large task execution graph. This is applicable for any area where there are a set of tasks that need to be combined based on incoming request.

From the coffee shop, to amazon warehouses, to your software program.

Looking at software

1. Greedy Evaluation…

Milk_froth = milk_froth_maker(Milk)
Boiled_water = water_boil(Water)
Coffee_powder = grind_beans(CoffeeBeans)

if request = ‘americano’:

Coffee = make_americano(Boiled_water, Coffee_powder)

else if request = ‘cappucino’:

Coffee = make_cappucion(Boiled_water, Coffee_powder, Milk_froth)

# water is boiled, milk is frothed, coffee is powedered
# but wait we were asked for an americano no milk!

2. Lazy Evaluation…

milk_froth_maker()
water_boil()
grind_beans()

if request = ‘americano’:

Coffee = make_americano(water_boil(Water), grind_beans(CoffeeBeans))

else if request = ‘cappucino’:

Coffee = make_cappucion(water_boil(Water), grind_beans(CoffeeBeans), milk_froth_maker(Milk))

# nothing is actually done till the request comes in
# therefore we save energy and cost

Stubbing Agents in a Multi-Agent System

Developing agents and multi-agent systems seems to be more about a lone-wolf developer standing up a whole fleet of agents (cue your favourite OpenClaw story).

But what if as an organisation you want to have a multi-agent system built with developers working across different teams?

In that scenario, all the agents you need in your multi-agent system (MAS) are not going to show up all at once in perfect sync. For a (hopefully) short time it will appear fragmented before the agents start coming online giving it some shape.

This post is about how you can stub out agents to ensure teams are decoupled.

This post is divided into two sections. The first one describes what a stub can look like and the other how these stubs fit based on specific orchestration scenarios.

Code and results can be found here.

Types of Stubs

Dumb Stub

This type of stub is just a bridge over the gap. The primary benefit is to ensure you can test any kind of routing and orchestration to this agent (not from it). This also allows you to see the shape of your multi-agent system and ensure you have placeholders to map on-paper architecture to code.

class StubAgent(BaseAgent):
def __init__(self, name:str, description:str):
super().__init__(name=name, description=description)
@override
async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]:
pass

Usage: The name an description are minimum bits of information you need to agree with the team responsible for the agent build. This must be done as a part of the MAS design and agent-to-skill mapping.

Deterministic Stub

We may want to create a deterministic stub for the situation where we know the major scenarios we want to stub for and can create a rule-based decision tree. For example, in case these agents are part of a sequence and are taking in structured input and producing structured output. Such rule-based stubs will then be replaced by a flexible/robust understanding and decisioning system (e.g., LLM, ML-model) in production.

Usage: First decide what scenarios you want to use the stub based on the requirements for the agent. Decide whether you want to test the happy path or the unhappy path or both. The problem to solve then is to create some simple rules that map the input to specific outputs. This will require coordination with the team developing the actual agent and the goals/tasks assigned to the agent. The stub can also be used to support session state update testing (plumbing for the system).

In the figure below if we are missing an agent in a Sequential workflow that we need to stub then we have three scenarios.

  1. Agent missing at the start of the flow – the stub will need to create output that drives the rest of the flow. This can be done based on specific flow scenarios (e.g., customer details passed in a structured format). This is a critical stub as it can either protect the downstream agents or push them off track.
    • State: the stub can be used to initialise session state.
  2. Agent missing in the middle of the flow – the stub will need to deal with input from the upstream agent as well as produce output to continue the scenario. We need to localise the behaviour of the stubbed agent.
    • State: the stub can be used to propagate state changes downstream aligning with the specific use-case.
  3. Agent missing at the end of the flow – this stub needs to capture the end state of the flow for whatever is waiting at the other end. We have to be careful as these types of stubs can misrepresent the entire flow.
    • State: the stub can be used to finalise state change at the end of the flow.
Agents in a sequence and stubs represented by dashed line.

Basic variant of the Deterministic Stub is shown below with all the different ‘action’ options:

  • Just generate some content based on a rule (append ‘Hello world’).
  • Update some state variable (hop count).
  • Indicate a transfer to another agent.
class DeterministicStubAgent(BaseAgent):
integration: str = "Stub_Agent_Integration"
def __init__(self, name: str, description: str, sub_agents=[]):
super().__init__(name=name, description=description)
@override
async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]:
# Get the input message from the context
logger.info(f"{self.name} received context: {ctx}")
input_message = ctx.user_content.parts[0].text if ctx.user_content and ctx.user_content.parts else ""
print(f"Received input message: {input_message}")
# Add "Hello world" to the message
# Put your custom code here...
modified_message = f"Hello world {input_message}"
print(f"Modified message: {modified_message}")
hop_count = ctx.session.state.get("hop_count", 0)
state_delta = {"hop_count": hop_count + 1
}
invocation_id = f"{hop_count}_{random.randint(1000, 9999)}"
if hop_count>=2 and hop_count<5:
# Transfer to another cheaper agent after 2 hops
action = EventActions(state_delta=state_delta,transfer_to_agent=self.integration)
event = Event( invocation_id=invocation_id, author=self.name, content = Content(role="assistant", parts=[Part(function_call={"name": "transfer_to_agent", "args": {"agent_name": self.integration}})]), actions=action)
elif hop_count>=5:
# Complete the turn for this agent.
print("Turn completed")
action = EventActions(state_delta=state_delta,turn_complete=True)
event = Event( invocation_id=invocation_id, author=self.name, content = Content(role="assistant", parts=[Part(text=modified_message)]), actions=action)
else:
# Update state change only.
action = EventActions(state_delta=state_delta)
event = Event( author=self.name, content = Content(role="assistant", parts=[Part(text=modified_message)]), actions=action)
# End of custom code: Remember to yield an event!
yield event

Intelligent Stub

This is when you find it difficult to create code that maps inputs to outputs but still need to stub out at the comprehension behaviour of the agent.

Usage: Ensure you focus on the inputs and outputs while stubbing out the comprehension aspect of the real agent. Be careful you do not add decisioning behaviours to the stub otherwise you will create a system that is tuned to the stub behaviour and may behave differently when the stub is replaced with the real agent.

You are able to relate the input with the output using one of the methods below:

  1. some sort of semantic search (e.g., vector search) where the index gives semantic mapping to the test output to use and ML-model is used to understand the input.
  2. prompt a light-weight LLM to understand the input and map to one of the pre-set outputs without exercising its own decisioning capabilities.

Example

I show an example of how such an ‘intelligent’ stub can be developed using the first approach (semantic search). For this we have used a set of ‘key intents’ aligned with the use-case, an embedding model to simulate the comprehension of the agent and a simple similarity score to surface the intent based on the input.

class IntelligentStubAgent(BaseAgent):
model: SentenceTransformer = SentenceTransformer('all-MiniLM-L6-v2')
key_intents: list[str] = ["Integration", "Differentiation", "Algebra", "Geometry", "Trigonometry"]
encoded_intents: list[np.ndarray] = []
def __init__(self, name: str, description: str, sub_agents=[]):
super().__init__(name=name, description=description)
self.encoded_intents = [self.model.encode(intent) for intent in self.key_intents]
@override
async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]:
# Get the input message from the context
logger.info(f"{self.name} received context: {ctx}")
input_message = ctx.user_content.parts[0].text if ctx.user_content and ctx.user_content.parts else ""
print(f"Received input message: {input_message}")
# encode incoming message for similarity search
encode = self.model.encode(input_message) # Just to simulate some processing
# generate similarity score and select best intent
similarities = [1 - util.cos_sim(encode, intent) for intent in self.encoded_intents]
best_intent_index = np.argmin(similarities)
best_intent = self.key_intents[best_intent_index]
modified_message = f"Identified intent: {best_intent} for input: {input_message}"
event = build_event( name=self.name, content=f"Detected intent: {modified_message}", state_delta={"identified_intent": best_intent})
yield event
# Nothing to see here - helper method to build an event object.
def build_event(name:str, content:str, turn_complete:bool=False, transfer_to_agent:str=None, state_delta:dict={})->Event:
action = EventActions(state_delta=state_delta, transfer_to_agent=transfer_to_agent)
invocation_id = f"{name}-{random.randint(1, 99999)}"
event = Event( invocation_id=invocation_id, author=name, content = Content(role="assistant", parts=[Part(text=content)]), actions=action, turn_complete=turn_complete)
return event

Conclusions

The stubs shown in this post can be used as sub-agents or within the deterministic workflows supported by ADK (looping, sequential, parallel). In the next post I will attempt to build out the deterministic workflow examples.

Below is the full example for sub-agents.


from typing import AsyncGenerator

from google.adk.agents.llm_agent import LlmAgent
from google.adk.agents import BaseAgent, InvocationContext
from google.adk.models.lite_llm import LiteLlm
from typing_extensions import override
from google.adk.events import Event, EventActions
from google.genai.types import Content, Part
import random
import numpy as np
from sentence_transformers import SentenceTransformer, util

import logging 
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

MODEL = LiteLlm(model="ollama_chat/qwen3.5:2b")

class StubAgent(BaseAgent):
    def __init__(self, name:str, description:str, sub_agents=[]):
        super().__init__(name=name, description=description, sub_agents=sub_agents)

    @override
    async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]:
        print("Activating StubAgent with context:", ctx)
        logger.info(f"{self.name} received context: {ctx}")
        yield Event(turn_complete=True, author=self.name)
        

class DeterministicStubAgent(BaseAgent):
    integration: str = "Stub_Agent_Integration"
    def __init__(self, name: str, description: str, sub_agents=[]):
        super().__init__(name=name, description=description)

    
    @override
    async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]:
        # Get the input message from the context
        logger.info(f"{self.name} received context: {ctx}")
        input_message = ctx.user_content.parts[0].text if ctx.user_content and ctx.user_content.parts else ""
        print(f"Received input message: {input_message}")
        # Add "Hello world" to the message
        
        modified_message = f"Hello world {input_message}"
        print(f"Modified message: {modified_message}")

        hop_count = ctx.session.state.get("hop_count", 0)
        state_delta = {"hop_count": hop_count + 1
        }

        invocation_id = f"{hop_count}_{random.randint(1000, 9999)}"

        

        if hop_count>=2 and hop_count<5:
            # Transfer to another cheaper agent after 2 hops
            action = EventActions(state_delta=state_delta,transfer_to_agent=self.integration)
            event = Event( invocation_id=invocation_id, author=self.name, content = Content(role="assistant", parts=[Part(function_call={"name": "transfer_to_agent", "args": {"agent_name": self.integration}})]), actions=action)
        
        elif hop_count>=5:
            print("Turn completed")
            action = EventActions(state_delta=state_delta,turn_complete=True)
            event = Event( invocation_id=invocation_id, author=self.name, content = Content(role="assistant", parts=[Part(text=modified_message)]), actions=action)

        else:
            action = EventActions(state_delta=state_delta)
            event = Event( author=self.name, content = Content(role="assistant", parts=[Part(text=modified_message)]), actions=action)
        

        yield event

class IntelligentStubAgent(BaseAgent):
    model: SentenceTransformer = SentenceTransformer('all-MiniLM-L6-v2')
    key_intents: list[str] = ["Integration", "Differentiation", "Algebra", "Geometry", "Trigonometry"]
    encoded_intents: list[np.ndarray]  = []

    def __init__(self, name: str, description: str, sub_agents=[]):
        super().__init__(name=name, description=description)
        self.encoded_intents = [self.model.encode(intent) for intent in self.key_intents]

    
    @override
    async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, None]:
        
        # Get the input message from the context
        logger.info(f"{self.name} received context: {ctx}")
        input_message = ctx.user_content.parts[0].text if ctx.user_content and ctx.user_content.parts else ""
        print(f"Received input message: {input_message}")
        # Add "Hello world" to the message

        encode = self.model.encode(input_message)  # Just to simulate some processing

        similarities = [1 - util.cos_sim(encode, intent) for intent in self.encoded_intents]
        best_intent_index = np.argmin(similarities)
        best_intent = self.key_intents[best_intent_index]

        modified_message = f"Identified intent: {best_intent} for input: {input_message}"

        event = build_event( name=self.name, content=f"Detected intent: {modified_message}", state_delta={"identified_intent": best_intent})

        yield event
    
def build_event(name:str, content:str, turn_complete:bool=False, transfer_to_agent:str=None, state_delta:dict={})->Event:
    action = EventActions(state_delta=state_delta, transfer_to_agent=transfer_to_agent)
    invocation_id = f"{name}-{random.randint(1, 99999)}"
    event = Event( invocation_id=invocation_id, author=name, content = Content(role="assistant", parts=[Part(text=content)]), actions=action, turn_complete=turn_complete)
    return event



instruction = """
You are an autonomous agent that takes a complex maths problem and breaks it down into smaller steps to solve it. 
You have access to a set of agents for each branch of maths.
"""


stub_agent_1 = StubAgent(name="Stub_Agent_Integration", description="Agent that can do Integration problems")
stub_agent_2 = DeterministicStubAgent(name="Stub_Agent_Differentiation", description="Agent that can do Differentiation problems")
stub_agent_3 = IntelligentStubAgent(name="Stub_Agent_Intelligent", description="Agent that can identify the branch of maths")
                                    
root_agent = LlmAgent(name="Root_Agent", description="Root agent for handling conversation and classification of problem", instruction=instruction, model=MODEL, sub_agents=[stub_agent_1, stub_agent_2, stub_agent_3])

Output:

Intelligent Stub in action

In the above the intent has been correctly identified and now can be used to pull out a specific response from a test list. ADK web tracing shows that the Intelligent stub was called.

Deterministic Stub in action

In the above the flow has been directed to the deterministic agent which as appended ‘Hello world’ to the output correctly. Confirmed using the ADK web tracing.

Dumb Stub (not) in action

In the above we don’t see anything interesting (given this is a Dumb stub) except that the Root Agent correctly routed to it based on the description provided to the dumb stub. This can be extremely useful when you have a large set of sub-agents and not all of them are available to test the routing result of your root agent instructions.