5.1.45. Debug Agents

This tutorial covers debug agents — persistent objects that live in their own separate context and can intercept runtime events, collect state, and host shared data accessible from any context.

Prerequisites: Compiling and Running Programs at Runtime (Tutorial 44) for invoke_in_context basics.

options gen2

require debugapi
require rtti

5.1.45.1. Creating and installing a debug agent

A debug agent is a class that extends DapiDebugAgent. The fundamental pattern is:

  1. Define a class that extends DapiDebugAgent

  2. Write a setup function that creates the agent and installs it under a name with install_new_debug_agent

  3. Call fork_debug_agent_context(@@setup) to clone the current context and run the setup function in it

The agent lives in its own “agent context” — a separate copy of the program that stays resident:

class CounterAgent : DapiDebugAgent {
    count : int = 0
}

def install_counter(ctx : Context) {
    install_new_debug_agent(new CounterAgent(), "counter")
}

def demo_create_agent() {
    print("  has 'counter' = {has_debug_agent_context("counter")}\n")
    fork_debug_agent_context(@@install_counter)
    print("  has 'counter' = {has_debug_agent_context("counter")}\n")
// output:
//   has 'counter' = false
//   has 'counter' = true

5.1.45.2. Intercepting log output with onLog

DapiDebugAgent has an onLog method called whenever any context prints or logs. If onLog returns true, the default output to stdout is suppressed. If it returns false, output proceeds normally.

This is how profiling tools, IDE log panels, and custom loggers intercept program output:

var log_intercept_count : int = 0

class LogAgent : DapiDebugAgent {
    def override onLog(context : Context?; at : LineInfo const?;
                       level : int; text : string#) : bool {
        log_intercept_count++
        return false  // don't suppress — let output reach stdout
    }
}

def install_log_agent(ctx : Context) {
    install_new_debug_agent(new LogAgent(), "log_watcher")
}

[export, pinvoke]
def read_log_count(var result : int?) {
    unsafe {
        *result = log_intercept_count
    }
}

// After forking + installing, each print/to_log triggers onLog
fork_debug_agent_context(@@install_log_agent)
print("  hello through agent\n")
to_log(LOG_INFO, "  info message\n")

var count = 0
unsafe {
    invoke_in_context(get_debug_agent_context("log_watcher"),
        "read_log_count", addr(count))
}
print("  log_intercept_count >= 2: {count >= 2}\n")
// output:
//   hello through agent
//   info message
//   log_intercept_count >= 2: true

5.1.45.3. Calling functions in the agent context

Use invoke_in_context to call [export, pinvoke] functions in the agent context. get_debug_agent_context(name) returns the agent’s Context. Functions run in that context, so they see the agent’s copy of module-level variables — not the caller’s.

The [pinvoke] annotation is required — it enables the context mutex needed for cross-context invocation.

To return values, pass a pointer to a result variable:

var agent_counter : int = 0

[export, pinvoke]
def agent_increment() {
    agent_counter++
}

[export, pinvoke]
def agent_get(var result : int?) {
    unsafe {
        *result = agent_counter
    }
}

def demo_invoke_in_context() {
    unsafe {
        invoke_in_context(get_debug_agent_context("counter"), "agent_increment")
        invoke_in_context(get_debug_agent_context("counter"), "agent_increment")
        invoke_in_context(get_debug_agent_context("counter"), "agent_increment")
    }
    var result = 0
    unsafe {
        invoke_in_context(get_debug_agent_context("counter"), "agent_get", addr(result))
    }
    print("  agent_counter (in agent) = {result}\n")
    print("  agent_counter (local)    = {agent_counter}\n")
// output:
//   agent_counter (in agent) = 3
//   agent_counter (local)    = 0

5.1.45.4. Calling agent methods with invoke_debug_agent_method

invoke_debug_agent_method calls a method on the agent’s class instance directly — no [export, pinvoke] helper functions needed. The agent’s self is passed automatically.

Syntax: invoke_debug_agent_method("agent_name", "method", args...)

class CalcAgent : DapiDebugAgent {
    accumulator : int = 0
    def add(amount : int) {
        self.accumulator += amount
    }
    def get_result(var result : int?) {
        unsafe {
            *result = self.accumulator
        }
    }
}

def install_calc_agent(ctx : Context) {
    install_new_debug_agent(new CalcAgent(), "calc")
}

fork_debug_agent_context(@@install_calc_agent)

unsafe {
    invoke_debug_agent_method("calc", "add", 10)
    invoke_debug_agent_method("calc", "add", 20)
    invoke_debug_agent_method("calc", "add", 12)
}

var result = 0
unsafe {
    invoke_debug_agent_method("calc", "get_result", addr(result))
}
print("  accumulator = {result}\n")
// output:
//   accumulator = 42

5.1.45.5. State collection — onCollect and onVariable

onCollect is called when collect_debug_agent_state is triggered. The agent can report custom variables via report_context_state. onVariable receives each reported variable — this is how IDE debuggers show custom watch variables and application diagnostics:

class StateAgent : DapiDebugAgent {
    collection_count : int = 0
    def override onCollect(var ctx : Context; at : LineInfo) : void {
        collection_count++
        unsafe {
            let tinfo = typeinfo rtti_typeinfo(collection_count)
            report_context_state(ctx, "Diagnostics", "collection_count",
                unsafe(addr(tinfo)), unsafe(addr(collection_count)))
        }
    def override onVariable(var ctx : Context; category, name : string;
                            info : TypeInfo; data : void?) : void {
        unsafe {
            let value = sprint_data(data, addr(info), print_flags.singleLine)
            print("  {category}: {name} = {value}\n")
        }
}

// Trigger collection
collect_debug_agent_state(this_context(), get_line_info(1))
// output:
//   Diagnostics: collection_count = 1

5.1.45.6. Agent existence checks

has_debug_agent_context(name) checks if a named agent exists. Always check before accessing the context to avoid panics:

print("  has 'counter'  = {has_debug_agent_context("counter")}\n")
print("  has 'missing'  = {has_debug_agent_context("missing")}\n")
// output:
//   has 'counter'  = true
//   has 'missing'  = false

5.1.45.7. Auto-start module pattern

In modules, agents are installed automatically via a [_macro] function. Four guards ensure safe, single installation:

[_macro]
def private auto_start() {
    if (is_compiling_macros_in_module("my_module") && !is_in_completion()) {
        if (!is_in_debug_agent_creation()) {
            if (!has_debug_agent_context("my_agent")) {
                fork_debug_agent_context(@@my_agent_setup)
            }
        }
    }
}

The guards prevent:

  • Running outside the module’s own compilation

  • Running during IDE code completion

  • Recursive agent creation

  • Duplicate installation

5.1.45.8. Plain agent as named context host

A common pattern is to create a plain DapiDebugAgent (no overrides) just to own a named context. Module-level variables in that context become shared state accessible via invoke_in_context. This is the foundation of the [apply_in_context] pattern (Tutorial 46):

var shared_data : int = 0

[export, pinvoke]
def add_data(amount : int) {
    shared_data += amount
}

[export, pinvoke]
def get_data(var result : int?) {
    unsafe {
        *result = shared_data
    }
}

def install_data_host(ctx : Context) {
    install_new_debug_agent(new DapiDebugAgent(), "data_host")
}

// Multiple calls accumulate in the agent's copy
unsafe {
    invoke_in_context(get_debug_agent_context("data_host"), "add_data", 10)
    invoke_in_context(get_debug_agent_context("data_host"), "add_data", 20)
}
// output:
//   shared_data (in agent) = 30
//   shared_data (local)    = 0

5.1.45.9. Shutting down a debug agent

delete_debug_agent_context removes an agent by name. It notifies all other agents via onUninstall, then safely destroys the agent and its context:

// Remove the agent
delete_debug_agent_context("data_host")

print("has 'data_host' = {has_debug_agent_context("data_host")}\n")
// output: has 'data_host' = false

// Deleting a non-existent agent is a safe no-op
delete_debug_agent_context("data_host")

Use this when a profiling session ends, a debug tool is closed, or during test teardown to ensure agents do not leak across test files.

5.1.45.10. Quick reference

fork_debug_agent_context(@@fn)

Clone context and call setup function in clone

install_new_debug_agent(agent, name)

Register agent under a global name

has_debug_agent_context(name)

Check if named agent exists

get_debug_agent_context(name)

Get agent’s Context for invoke_in_context

delete_debug_agent_context(name)

Remove agent by name (safe no-op if missing)

invoke_in_context(ctx, fn_name, ...)

Call [export, pinvoke] function in agent context

invoke_debug_agent_method(name, meth, ...)

Call a method on the agent’s class instance

collect_debug_agent_state(ctx, line)

Trigger onCollect on all agents

report_context_state(ctx, cat, name, ...)

Report variable from onCollect to onVariable

is_in_debug_agent_creation()

True during fork_debug_agent_context

to_log(level, text)

Log message — routed through onLog if agents exist

[pinvoke]

Annotation enabling context mutex

See also

Full source: tutorials/language/45_debug_agents.das

Cross-Context Services with apply_in_context — cross-context services via [apply_in_context] annotation (Tutorial 46).

Compiling and Running Programs at Runtime — compiling and running programs at runtime (Tutorial 44).

Contexts — language reference for context semantics.

Previous tutorial: Compiling and Running Programs at Runtime

Next tutorial: Cross-Context Services with apply_in_context